diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index acf7bf05a..08eb08062 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -101,6 +101,13 @@ on: type: boolean required: false default: false + allow-unsigned-in-dev: + description: | + Escape hatch: when true, allowing unsigned dev builds in untrusted contexts. + Defaults to false (untrusted dev builds hard-fail). See ci.yml for details. + type: boolean + required: false + default: false signature-type: description: Specify signature type to use when signing the plugin type: string @@ -795,6 +802,7 @@ jobs: environment: ${{ inputs.environment }} allow-unsigned: ${{ inputs.allow-unsigned }} + allow-unsigned-in-dev: ${{ inputs.allow-unsigned-in-dev }} signature-type: ${{ inputs.signature-type }} dist-artifacts-prefix: ${{ inputs.dist-artifacts-prefix }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2893dcde5..f386f675e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,6 +234,13 @@ on: type: boolean required: false default: false + allow-unsigned-in-dev: + description: | + Escape hatch: when true, allow unsigned dev builds in untrusted contexts. + Defaults to false (untrusted dev builds hard-fail). PR/fork builds (env=`none`|`''`) keep the fallback either way. + type: boolean + required: false + default: false signature-type: description: Specify signature type to use when signing the plugin type: string @@ -386,7 +393,9 @@ jobs: 'dependabot[bot]', 'renovate-sh-app[bot]', // Used by other shared workflows such as the version bump workflow - 'grafana-plugins-platform-bot[bot]' + 'grafana-plugins-platform-bot[bot]', + // Only the actor on post-merge pushes from the queue, which already required maintainer approval. + 'github-merge-queue[bot]' ].map((s) => s.toLowerCase()); const isPR = context.eventName === 'pull_request'; @@ -545,6 +554,8 @@ jobs: secrets: ${{ (fromJson(steps.workflow-context.outputs.result).isTrusted && inputs.backend-secrets != '') && inputs.backend-secrets || '' }} build-target: ${{ inputs.backend-build-target }} + # Auto-allow-unsigned applies only to untrusted contexts AND envs where shipping unsigned is acceptable: + # `none`/`''` (CI-only) and, opt-in via `allow-unsigned-in-dev`, `dev`. Untrusted dev otherwise hard-fails here. - name: Package universal ZIP id: universal-zip uses: grafana/plugin-ci-workflows/actions/internal/plugins/package@main @@ -553,7 +564,7 @@ jobs: dist-folder: ${{ inputs.plugin-directory }}/dist output-folder: ${{ inputs.plugin-directory }}/dist-artifacts access-policy-token: ${{ fromJson(steps.workflow-context.outputs.result).isTrusted && fromJSON(steps.get-secrets.outputs.secrets).SIGN_PLUGIN_ACCESS_POLICY_TOKEN || '' }} - allow-unsigned: ${{ ((inputs.environment == 'dev' || inputs.environment == '' || inputs.environment == 'none') && !(fromJson(steps.workflow-context.outputs.result).isTrusted)) || inputs.allow-unsigned }} + allow-unsigned: ${{ ((inputs.environment == '' || inputs.environment == 'none' || (inputs.environment == 'dev' && inputs.allow-unsigned-in-dev)) && !(fromJson(steps.workflow-context.outputs.result).isTrusted)) || inputs.allow-unsigned }} signature-type: ${{ inputs.signature-type }} - name: Package os/arch ZIPs @@ -564,7 +575,7 @@ jobs: dist-folder: ${{ inputs.plugin-directory }}/dist output-folder: ${{ inputs.plugin-directory }}/dist-artifacts access-policy-token: ${{ fromJson(steps.workflow-context.outputs.result).isTrusted && fromJSON(steps.get-secrets.outputs.secrets).SIGN_PLUGIN_ACCESS_POLICY_TOKEN || '' }} - allow-unsigned: ${{ ((inputs.environment == 'dev' || inputs.environment == '' || inputs.environment == 'none') && !(fromJson(steps.workflow-context.outputs.result).isTrusted)) || inputs.allow-unsigned }} + allow-unsigned: ${{ ((inputs.environment == '' || inputs.environment == 'none' || (inputs.environment == 'dev' && inputs.allow-unsigned-in-dev)) && !(fromJson(steps.workflow-context.outputs.result).isTrusted)) || inputs.allow-unsigned }} signature-type: ${{ inputs.signature-type }} - name: Trufflehog secrets scanning diff --git a/tests/act/internal/workflow/ci/ci.go b/tests/act/internal/workflow/ci/ci.go index 4b5f10ab8..c1f1940c9 100644 --- a/tests/act/internal/workflow/ci/ci.go +++ b/tests/act/internal/workflow/ci/ci.go @@ -110,6 +110,7 @@ type WorkflowInputs struct { RunTruffleHog *bool AllowUnsigned *bool + AllowUnsignedInDev *bool Testing *bool BackendBuildTarget *string @@ -138,6 +139,7 @@ func SetCIInputs(dst *workflow.Job, inputs WorkflowInputs) { workflow.SetJobInput(dst, "run-trufflehog", inputs.RunTruffleHog) workflow.SetJobInput(dst, "allow-unsigned", inputs.AllowUnsigned) + workflow.SetJobInput(dst, "allow-unsigned-in-dev", inputs.AllowUnsignedInDev) workflow.SetJobInput(dst, "testing", inputs.Testing) workflow.SetJobInput(dst, "backend-build-target", inputs.BackendBuildTarget) diff --git a/tests/act/internal/workflow/ci/ci_test.go b/tests/act/internal/workflow/ci/ci_test.go new file mode 100644 index 000000000..a562c809c --- /dev/null +++ b/tests/act/internal/workflow/ci/ci_test.go @@ -0,0 +1,67 @@ +package ci + +import ( + "testing" + + "github.com/grafana/plugin-ci-workflows/tests/act/internal/workflow" + "github.com/stretchr/testify/require" +) + +// newJobWithEmptyInputs returns a fresh Job with an initialized With map. +// SetCIInputs writes into job.With, so callers must ensure it is non-nil. +func newJobWithEmptyInputs() *workflow.Job { + return &workflow.Job{With: map[string]any{}} +} + +func TestSetCIInputs_AllowUnsignedInDev(t *testing.T) { + t.Parallel() + + const inputKey = "allow-unsigned-in-dev" + + t.Run("nil leaves input unset (workflow default applies)", func(t *testing.T) { + t.Parallel() + + job := newJobWithEmptyInputs() + SetCIInputs(job, WorkflowInputs{}) + + require.NotContains(t, job.With, inputKey, + "unset AllowUnsignedInDev should not propagate %q so the workflow's default (false) applies", inputKey) + }) + + t.Run("true is forwarded as the escape-hatch opt-in", func(t *testing.T) { + t.Parallel() + + job := newJobWithEmptyInputs() + SetCIInputs(job, WorkflowInputs{ + AllowUnsignedInDev: workflow.Input(true), + }) + + require.Equal(t, true, job.With[inputKey], + "AllowUnsignedInDev=true must propagate as %q=true to opt back into the pre-v8 untrusted-dev fallback", inputKey) + }) + + t.Run("false is forwarded explicitly", func(t *testing.T) { + t.Parallel() + + job := newJobWithEmptyInputs() + SetCIInputs(job, WorkflowInputs{ + AllowUnsignedInDev: workflow.Input(false), + }) + + require.Equal(t, false, job.With[inputKey], + "AllowUnsignedInDev=false must propagate as %q=false to keep the hard-fail default", inputKey) + }) + + t.Run("does not collide with allow-unsigned", func(t *testing.T) { + t.Parallel() + + job := newJobWithEmptyInputs() + SetCIInputs(job, WorkflowInputs{ + AllowUnsigned: workflow.Input(true), + AllowUnsignedInDev: workflow.Input(false), + }) + + require.Equal(t, true, job.With["allow-unsigned"], "AllowUnsigned should map to %q", "allow-unsigned") + require.Equal(t, false, job.With[inputKey], "AllowUnsignedInDev should map to %q", inputKey) + }) +}