From f8defec0eb2c466c43a0396b87fe70d69730c900 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 5 Sep 2026 12:10:04 -0400 Subject: [PATCH 1/2] test: reproduce missing release after fast retag (#1956) --- .github/workflows/deploy.yml | 1 + test-all.sh | 1 + test-issue-1956-release-routing.js | 165 +++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 test-issue-1956-release-routing.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6b68ff79..e2771397 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -235,6 +235,7 @@ jobs: node test-issue-1705-subpath-contrast.js node test-issue-1770-mobile-row-clamp.js node test-a11y-axe-routes-coverage.js + node test-issue-1956-release-routing.js - name: 🛡️ Preflight XSS gate — actual --diff check (PR only) # The fixture self-test above (test-preflight-xss-gate.js) only diff --git a/test-all.sh b/test-all.sh index bd3b0b69..e8d692c0 100755 --- a/test-all.sh +++ b/test-all.sh @@ -40,6 +40,7 @@ node test-issue-1648-m6-lint-self.js node test-issue-1890-og-url.js node test-traces.js node test-live-multibyte-filter.js +node test-issue-1956-release-routing.js # #1418 — route-view v2 (Tufte) coverage node test-issue-1418-raw-hex-extraction.js diff --git a/test-issue-1956-release-routing.js b/test-issue-1956-release-routing.js new file mode 100644 index 00000000..98134188 --- /dev/null +++ b/test-issue-1956-release-routing.js @@ -0,0 +1,165 @@ +// Run the real release shell steps with registry/dispatch commands stubbed out. +// No GitHub writes or container builds: node test-issue-1956-release-routing.js +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const { spawnSync } = require('node:child_process'); + +const read = name => fs.readFileSync(path.join(__dirname, '.github/workflows', name), 'utf8').replace(/\r/g, ''); +const fast = read('release-fast-path.yml'); +const deploy = read('deploy.yml'); + +// Extract known YAML blocks, retaining the actual expressions and shell code. +// Full YAML syntax is separately checked by actionlint; no YAML dependency here. +function block(source, key, indent) { + const lines = source.split('\n'); + const prefix = ' '.repeat(indent) + key + ':'; + const start = lines.findIndex(line => line.startsWith(prefix)); + if (start < 0) return ''; + let end = start + 1; + while (end < lines.length && (!lines[end].trim() || lines[end].search(/\S/) > indent)) end++; + return lines.slice(start, end).join('\n'); +} + +function value(source, key, indent) { + const raw = block(source, key, indent); + if (!raw) return ''; + const lines = raw.split('\n'); + const first = lines[0].slice(indent + key.length + 1).trim(); + return first === '|' || first === '>' + ? lines.slice(1).map(line => line.slice(indent + 2)).join('\n').trimEnd() + : first; +} + +const steps = source => source.split(/(?=^ - name:)/m).slice(1); +function evaluate(expression, context) { + if (!expression) return true; + return vm.runInNewContext(expression.replace(/^\$\{\{|\}\}$/g, '').trim(), { + ...context, startsWith: (text, prefix) => text.startsWith(prefix) + }); +} +const expand = (script, context) => script.replace(/\$\{\{(.*?)\}\}/g, (_, expression) => String(evaluate(expression, context))); +const bashPath = file => process.platform === 'win32' ? file.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, drive) => '/' + drive.toLowerCase()) : file; + +function runSteps(source, context, edge, mutateFails = false) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'corescope-release-test-')); + const output = path.join(dir, 'output'); + const log = path.join(dir, 'commands'); + fs.writeFileSync(log, ''); + fs.mkdirSync(path.join(dir, 'cmd/decrypt'), { recursive: true }); + const stubs = ` + log() { node -e 'require("fs").appendFileSync(process.env.COMMAND_LOG, JSON.stringify(process.argv.slice(1))+"\\n")' "$@"; } + crane() { + if [ "$1" = config ]; then + [ "$EDGE_CONFIG" != missing ] || return 1 + printf '%s' "$EDGE_CONFIG" + else + log crane "$@" + [ "$1" != mutate ] || [ "$MUTATE_FAILS" != true ] + fi + } + gh() { log gh "$@"; } + tar() { log tar "$@"; } + go() { log go "$GOOS" "$GOARCH" "$CGO_ENABLED" "$@"; } + jq() { + node -e 'const fs=require("fs"); const assert=require("assert/strict"); assert.equal(process.argv[1], ".config.Labels[\\"org.opencontainers.image.revision\\"] // \\\"\\\""); console.log(JSON.parse(fs.readFileSync(0,"utf8")).config.Labels["org.opencontainers.image.revision"] || "")' "$2" + } + `; + try { + for (const step of steps(source)) { + const script = value(step, 'run', 8); + if (!script || !evaluate(value(step, 'if', 8), context)) continue; + fs.writeFileSync(output, ''); + const result = spawnSync(process.env.BASH_PATH || 'bash', ['--noprofile', '--norc', '-e', '-o', 'pipefail'], { + input: stubs + '\n' + expand(script, context), cwd: dir, encoding: 'utf8', timeout: 15000, + env: { + ...process.env, GITHUB_REF: context.github.ref, GITHUB_SHA: context.github.sha, + GITHUB_OUTPUT: bashPath(output), COMMAND_LOG: bashPath(log), TMPDIR: bashPath(dir), + EDGE_CONFIG: edge === null ? 'missing' : JSON.stringify({ config: { Labels: { 'org.opencontainers.image.revision': edge } } }), + MUTATE_FAILS: String(mutateFails) + } + }); + if (mutateFails && result.status !== 0) return { commands: commands(), failed: true }; + assert.equal(result.status, 0, `${value(step, '- name', 6)}: ${result.error || result.stderr}`); + const id = value(step, 'id', 8); + if (id) context.steps[id] = { outputs: Object.fromEntries(fs.readFileSync(output, 'utf8').trim().split('\n').filter(Boolean).map(line => { + const at = line.indexOf('='); + return [line.slice(0, at), line.slice(at + 1)]; + })) }; + } + return { commands: commands(), failed: false }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + function commands() { return fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); } +} + +function context(ref = 'refs/tags/v9.8.7', event = 'workflow_dispatch', inputs = {}) { + return { github: { ref, ref_name: ref.split('/').pop(), sha: 'a'.repeat(40), repository: 'example/corescope', event_name: event }, inputs, steps: {}, needs: {}, vars: {} }; +} + +// Honor the real job conditions AND implicit success() for needs. This catches +// release-artifacts accidentally depending on the skipped image or E2E jobs. +function route(ctx, failedJob) { + for (const name of ['changes', 'go-test', 'e2e-test', 'build-and-publish', 'release-artifacts', 'deploy', 'publish']) { + const job = block(deploy, name, 2); + assert.ok(job, `missing ${name} job`); + const needs = value(job, 'needs', 4).replace(/[\[\]\s]/g, '').split(',').filter(Boolean); + const run = needs.every(need => ctx.needs[need].result === 'success') && evaluate(value(job, 'if', 4), ctx); + ctx.needs[name] = { result: run ? (name === failedJob ? 'failure' : 'success') : 'skipped', outputs: { code: 'true' } }; + } + return ctx.needs; +} + +for (const [name, edge] of [['matching', 'a'.repeat(40)], ['missing', null], ['mismatched', 'b'.repeat(40)]]) { + const ctx = context(); + const { commands } = runSteps(block(fast, 'retag-or-fallback', 2), ctx, edge); + const dispatches = commands.filter(command => command[0] === 'gh'); + assert.equal(dispatches.length, 1, `${name}: tag must dispatch the artifact workflow exactly once`); + const dispatch = dispatches[0]; + assert.deepEqual(dispatch.slice(1, 4), ['workflow', 'run', 'deploy.yml']); + assert.equal(dispatch[dispatch.indexOf('--ref') + 1], ctx.github.ref, `${name}: dispatch must preserve the tag source`); + assert.equal(dispatch[dispatch.indexOf('--repo') + 1], ctx.github.repository); + const matching = name === 'matching'; + assert.equal(dispatch.includes('images_published=true'), matching); + if (!matching) assert.ok(!dispatch.includes('--field') && !dispatch.includes('-f'), 'old-tag fallback must not require new workflow inputs'); + assert.equal(commands.filter(command => command[0] === 'crane' && command[1] === 'mutate').length, matching ? 1 : 0); + assert.deepEqual(commands.filter(command => command[0] === 'crane' && command[1] === 'tag').map(command => command.at(-1)), matching ? ['v9.8', 'v9', 'latest'] : []); + const jobs = route(context(undefined, undefined, { images_published: matching })); + assert.equal(jobs['release-artifacts'].result, 'success', `${name}: release artifacts must run`); + assert.equal(jobs['go-test'].result, 'success', `${name}: release still requires Go validation`); + for (const job of ['e2e-test', 'build-and-publish']) assert.equal(jobs[job].result, matching ? 'skipped' : 'success', `${name}: ${job}`); + assert.equal(jobs.deploy.result, 'skipped'); + assert.equal(jobs.publish.result, 'skipped'); + console.log(`PASS ${name} edge: one artifact dispatch, correct image route`); +} + +const failedRetag = runSteps(block(fast, 'retag-or-fallback', 2), context(), 'a'.repeat(40), true); +assert.equal(failedRetag.failed, true); +assert.equal(failedRetag.commands.filter(command => command[0] === 'gh').length, 0, 'failed retag must not dispatch with images_published=true'); + +for (const [ref, event] of [['refs/heads/master', 'push'], ['refs/heads/master', 'workflow_dispatch'], ['refs/pull/1/merge', 'pull_request']]) { + const jobs = route(context(ref, event, { images_published: true })); + assert.equal(jobs['release-artifacts'].result, 'skipped', `${event}: no GitHub release`); + assert.equal(jobs['build-and-publish'].result, 'success', `${event}: tag-only input must not skip branch/PR checks`); + const publishing = steps(block(deploy, 'build-and-publish', 2)).find(step => step.includes('uses: docker/build-push-action')); + assert.equal(Boolean(evaluate(value(publishing, 'if', 8), context(ref, event))), event === 'push', `${event}: GHCR publishing`); +} +assert.equal(route(context(), 'go-test')['release-artifacts'].result, 'skipped', 'failed Go validation must block release'); + +const release = block(deploy, 'release-artifacts', 2); +const builds = runSteps(release, context(), null).commands.filter(command => command[0] === 'go'); +assert.deepEqual(builds.map(command => command.slice(1, 4)), [['linux', 'amd64', '0'], ['linux', 'arm64', '0']]); +for (const command of builds) assert.ok(command.includes('-ldflags=-s -w -X main.version=v9.8.7'), 'binary version must come from tag'); +const upload = steps(release).filter(step => step.includes('uses: softprops/action-gh-release@v2')); +assert.equal(upload.length, 1, 'publish both architectures together, before the release becomes immutable'); +assert.equal(value(upload[0], 'fail_on_unmatched_files', 10), 'true', 'missing assets must prevent publication'); +assert.deepEqual(value(upload[0], 'files', 10).trim().split('\n').map(line => line.trim()), ['corescope-decrypt-linux-amd64', 'corescope-decrypt-linux-arm64']); +assert.equal(value(upload[0], 'draft', 10), '', 'standard release action must finalize after both uploads'); +assert.equal(value(upload[0], 'prerelease', 10), '', 'standard release action must upload before publishing'); +const checkout = steps(release).find(step => step.includes('uses: actions/checkout@')); +assert.equal(value(checkout, 'ref', 10), '', 'checkout must retain the dispatched tag/SHA'); +assert.ok(!value(block(deploy, 'push', 2), 'tags', 4), 'fast path must remain the sole tag-triggered image writer'); +console.log('PASS failed retag/Go gates, branch/PR routes, and complete tagged release assets'); From 364efe52085f2ddb85f33818d86cd8d095f92af0 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 5 Sep 2026 12:21:29 -0400 Subject: [PATCH 2/2] fix: publish release assets after successful fast retag (#1956) --- .github/workflows/deploy.yml | 12 ++++++++-- .github/workflows/release-fast-path.yml | 31 ++++++++++++++----------- test-issue-1956-release-routing.js | 12 +++++++++- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e2771397..feeb48b4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,6 +18,11 @@ on: pull_request: branches: [master] workflow_dispatch: + inputs: + images_published: + description: 'Release fast path already published the tag images' + type: boolean + default: false permissions: contents: read @@ -323,7 +328,7 @@ jobs: defaults: run: shell: bash - if: needs.changes.outputs.code == 'true' + if: needs.changes.outputs.code == 'true' && !(startsWith(github.ref, 'refs/tags/v') && inputs.images_published) steps: - name: Checkout code uses: actions/checkout@v5 @@ -666,7 +671,7 @@ jobs: name: "🏗️ Build & Publish Docker Image" needs: [e2e-test, changes] runs-on: ubuntu-latest - if: needs.changes.outputs.code == 'true' + if: needs.changes.outputs.code == 'true' && !(startsWith(github.ref, 'refs/tags/v') && inputs.images_published) steps: - name: Checkout code uses: actions/checkout@v5 @@ -769,8 +774,11 @@ jobs: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${{ github.ref_name }}" -o ../../corescope-decrypt-linux-arm64 . - name: Upload release assets + # Standard releases upload both assets to a draft before publishing. + # Keep one writer: published releases are immutable in this repository. uses: softprops/action-gh-release@v2 with: + fail_on_unmatched_files: true files: | corescope-decrypt-linux-amd64 corescope-decrypt-linux-arm64 diff --git a/.github/workflows/release-fast-path.yml b/.github/workflows/release-fast-path.yml index b7f1b47f..e71b9f6a 100644 --- a/.github/workflows/release-fast-path.yml +++ b/.github/workflows/release-fast-path.yml @@ -1,10 +1,10 @@ name: Release Fast-Path # Issue #1677: re-tag :edge as :vX.Y.Z when the tag SHA matches :edge's -# org.opencontainers.image.revision label. Skips ~30 min of Go test + -# Playwright + Docker rebuild because the bytes are identical — only the -# manifest name changes. Falls back to deploy.yml when SHAs differ so -# tags on older commits still go through full validation. +# org.opencontainers.image.revision label. Skips Playwright + Docker rebuild +# because the application bytes are identical. Both paths dispatch deploy.yml +# for Go validation and release binaries; mismatched SHAs also run the full +# image pipeline so tags on older commits still go through full validation. # # This workflow is the SOLE consumer of push.tags. deploy.yml's tag # trigger has been removed to prevent double-fire. @@ -16,7 +16,7 @@ on: permissions: contents: read packages: write - actions: write # issue #1702: required so the fallback `gh workflow run deploy.yml` dispatch is allowed + actions: write # issue #1702: required for the `gh workflow run deploy.yml` dispatch concurrency: group: release-fast-path-${{ github.ref }} @@ -24,7 +24,7 @@ concurrency: jobs: retag-or-fallback: - name: "🏷️ Re-tag :edge → :vX.Y.Z (fast) or dispatch deploy.yml (fallback)" + name: "🏷️ Re-tag :edge and dispatch release artifacts or full fallback" runs-on: ubuntu-latest steps: - name: Log in to GHCR @@ -115,17 +115,22 @@ jobs: done echo "Fast-path complete — release tags point at :edge plus a one-file version layer." - # ─────────── FALLBACK: SHAs differ, run the full pipeline ─────────── - - name: Dispatch full deploy.yml pipeline (fallback) - if: steps.edge.outputs.no_edge == 'true' || steps.edge.outputs.edge_revision != github.sha + # Both image routes need the single release-artifacts writer in deploy.yml. + - name: Dispatch release artifacts or full deploy.yml pipeline env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - echo "SHA mismatch (or no :edge) — falling back to full pipeline" - echo " :edge revision = '${{ steps.edge.outputs.edge_revision }}'" - echo " tag SHA = '${{ github.sha }}'" + DISPATCH_ARGS=() + if [[ "${{ steps.edge.outputs.no_edge == 'false' && steps.edge.outputs.edge_revision == github.sha }}" == 'true' ]]; then + echo "Images published — dispatching Go validation and release artifacts" + DISPATCH_ARGS+=(--field images_published=true) + else + # No new inputs: an older tag may have an older deploy.yml schema. + echo "SHA mismatch (or no :edge) — falling back to full pipeline" + fi gh workflow run deploy.yml \ --repo "${{ github.repository }}" \ - --ref "${{ github.ref }}" + --ref "${{ github.ref }}" \ + "${DISPATCH_ARGS[@]}" echo "Dispatched deploy.yml against ${{ github.ref }}" diff --git a/test-issue-1956-release-routing.js b/test-issue-1956-release-routing.js index 98134188..0f73a4d7 100644 --- a/test-issue-1956-release-routing.js +++ b/test-issue-1956-release-routing.js @@ -10,6 +10,13 @@ const { spawnSync } = require('node:child_process'); const read = name => fs.readFileSync(path.join(__dirname, '.github/workflows', name), 'utf8').replace(/\r/g, ''); const fast = read('release-fast-path.yml'); const deploy = read('deploy.yml'); +let bash = process.env.BASH_PATH || 'bash'; +if (!process.env.BASH_PATH && process.platform === 'win32') { + // Prefer Git Bash over Windows' WSL launcher; CI uses the native Linux bash. + const git = spawnSync('git', ['--exec-path'], { encoding: 'utf8' }); + const gitBash = path.resolve((git.stdout || '').trim(), '../../../bin/bash.exe'); + if (git.status === 0 && fs.existsSync(gitBash)) bash = gitBash; +} // Extract known YAML blocks, retaining the actual expressions and shell code. // Full YAML syntax is separately checked by actionlint; no YAML dependency here. @@ -72,7 +79,7 @@ function runSteps(source, context, edge, mutateFails = false) { const script = value(step, 'run', 8); if (!script || !evaluate(value(step, 'if', 8), context)) continue; fs.writeFileSync(output, ''); - const result = spawnSync(process.env.BASH_PATH || 'bash', ['--noprofile', '--norc', '-e', '-o', 'pipefail'], { + const result = spawnSync(bash, ['--noprofile', '--norc', '-e', '-o', 'pipefail'], { input: stubs + '\n' + expand(script, context), cwd: dir, encoding: 'utf8', timeout: 15000, env: { ...process.env, GITHUB_REF: context.github.ref, GITHUB_SHA: context.github.sha, @@ -148,6 +155,9 @@ for (const [ref, event] of [['refs/heads/master', 'push'], ['refs/heads/master', assert.equal(Boolean(evaluate(value(publishing, 'if', 8), context(ref, event))), event === 'push', `${event}: GHCR publishing`); } assert.equal(route(context(), 'go-test')['release-artifacts'].result, 'skipped', 'failed Go validation must block release'); +const dispatchInput = block(deploy, 'images_published', 6); +assert.equal(value(dispatchInput, 'type', 8), 'boolean', 'dispatch flag must retain boolean semantics'); +assert.equal(value(dispatchInput, 'default', 8), 'false', 'manual and fallback dispatches must build images by default'); const release = block(deploy, 'release-artifacts', 2); const builds = runSteps(release, context(), null).commands.filter(command => command[0] === 'go');