diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b1fbf73..ddef541 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -8,7 +8,8 @@ This directory contains GitHub Actions workflow definitions for CI/CD automation | -------------------------------------------- | -------------------------- | ------------------------------------ | | `create_tag.yml` | Push to `main` | Version bump and tag creation | | `publish_action.yml` | Tag push | Publish action to GitHub Marketplace | -| `pull_request_cleanup_tags_and_releases.yml` | PR closed / branch deleted | Clean up test tags and releases | +| `pull_request_cleanup_tags_and_releases.yml` | Manual / reusable call | Delete one verified test release and tag | +| `test-release-publication.yml` | Workflow-changing PR / manual | Verify isolated publication and exact cleanup | | `push_code_linting.yml` | Pull request | ESLint code review | ## Workflow Details @@ -58,31 +59,13 @@ This directory contains GitHub Actions workflow definitions for CI/CD automation ### pull_request_cleanup_tags_and_releases.yml -**Name**: Pull Request - Cleanup Tags and Releases +Cleanup is explicitly dispatched or called by the controlled publication test. Supply the exact `tag` in the form `vMAJOR.MINOR.PATCH-repair.RUN.ATTEMPT` and the full `expected_sha` recorded by the publisher. The workflow checks the tag target and prerelease status before deleting that release and tag. It preserves all production releases and workflow history. -**Triggers**: +If a deletion fails partway through, rerun with the same tag and SHA. Missing artifacts are treated as already removed; mismatched targets, stable releases and API permission failures stop cleanup. -- Branch deletion (`delete` event) -- Pull request closed -- Manual dispatch with optional inputs +### test-release-publication.yml -**Purpose**: Removes test/preview tags and releases created during PR development. - -**Inputs** (manual dispatch): -| Input | Required | Description | -|-------|----------|-------------| -| `regex` | No | Regex pattern to match tags for deletion | -| `pr_number` | No | PR number to find associated tags | - -**Steps**: - -1. Uses `Broadshield/github-action-cleanup-releases-and-tags@main` -2. Removes tags/releases matching the pattern or PR number - -**Environment**: - -- Runner: `ubuntu-latest` -- Token: `github.token` (automatic) +Changes to the cleanup/publication-test workflows run a controlled publication test on same-repository PRs. It can also be dispatched manually. The test creates a unique prerelease, attaches the checked-out packaged action, downloads and compares the artifact byte for byte, calls the cleanup workflow, and verifies that the exact test release and tag are absent. Each attempt uses a new run-specific tag and never updates an existing ref. ### push_code_linting.yml diff --git a/.github/workflows/pull_request_cleanup_tags_and_releases.yml b/.github/workflows/pull_request_cleanup_tags_and_releases.yml index 98d1ad5..98855f6 100644 --- a/.github/workflows/pull_request_cleanup_tags_and_releases.yml +++ b/.github/workflows/pull_request_cleanup_tags_and_releases.yml @@ -1,27 +1,64 @@ -name: 🧼 Pull Request - Cleanup Tags and Releases +name: Clean up a controlled test release on: - delete: - pull_request: - types: [closed] + workflow_call: + inputs: + tag: + required: true + type: string + expected_sha: + required: true + type: string workflow_dispatch: inputs: - regex: - description: 'Regex to search by' - required: false - pr_number: - description: 'PR Number to search by' - required: false + tag: + description: 'Exact test tag: vMAJOR.MINOR.PATCH-repair.RUN.ATTEMPT' + required: true + type: string + expected_sha: + description: 'Full commit SHA recorded when the test release was created' + required: true + type: string + +permissions: + contents: write + +concurrency: + group: cleanup-test-release-${{ inputs.tag }} + cancel-in-progress: false jobs: - test: - name: Cleanup Tags and Releases - runs-on: ubuntu-latest + cleanup: + runs-on: ubuntu-24.04 steps: - - name: Remove unused releases and tags - uses: Broadshield/github-action-cleanup-releases-and-tags@main + - name: Validate ownership and delete only the named test release and tag + uses: actions/github-script@v8 env: - GITHUB_TOKEN: ${{ github.token }} + TEST_TAG: ${{ inputs.tag }} + EXPECTED_SHA: ${{ inputs.expected_sha }} with: - regex: ${{ github.event.inputs.regex }} - pr_number: ${{ github.event.inputs.pr_number }} + script: | + const tag = process.env.TEST_TAG; + const expected = process.env.EXPECTED_SHA; + if (!/^v[0-9]+\.[0-9]+\.[0-9]+-repair\.[0-9]+\.[0-9]+$/.test(tag)) { + throw new Error('Only explicitly named repair test tags can be removed.'); + } + if (!/^[a-f0-9]{40}$/.test(expected)) { + throw new Error('The expected full commit SHA is required.'); + } + const absentOn404 = async (request) => { + try { return (await request()).data; } + catch (error) { if (error.status === 404) return null; throw error; } + }; + const commit = await absentOn404(() => github.rest.repos.getCommit({ ...context.repo, ref: tag })); + const release = await absentOn404(() => github.rest.repos.getReleaseByTag({ ...context.repo, tag })); + if (commit && commit.sha !== expected) throw new Error('Test tag does not target the expected commit.'); + if (release && (release.tag_name !== tag || !release.prerelease)) { + throw new Error('The named release must be a prerelease.'); + } + if (release && !commit && release.target_commitish !== expected) { + throw new Error('Cannot verify the target of a release whose tag is missing.'); + } + if (release) await github.rest.repos.deleteRelease({ ...context.repo, release_id: release.id }); + if (commit) await github.rest.git.deleteRef({ ...context.repo, ref: `tags/${tag}` }); + core.info(`Deleted controlled test release ${tag} at ${expected}.`); diff --git a/.github/workflows/test-release-publication.yml b/.github/workflows/test-release-publication.yml new file mode 100644 index 0000000..c653188 --- /dev/null +++ b/.github/workflows/test-release-publication.yml @@ -0,0 +1,91 @@ +name: Controlled release publication test +on: + pull_request: + paths: + - '.github/workflows/pull_request_cleanup_tags_and_releases.yml' + - '.github/workflows/test-release-publication.yml' + workflow_dispatch: + +permissions: + contents: write + +jobs: + publish: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + outputs: + tag: ${{ steps.publish.outputs.tag }} + sha: ${{ steps.publish.outputs.sha }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Package the exact checked-out action + run: git archive --format=tar.gz --output=action.tar.gz HEAD action.yml dist package.json README.md LICENSE + - name: Publish a unique prerelease without moving any existing ref + id: publish + uses: actions/github-script@v8 + with: + script: | + const fs = require('node:fs'); + const { execFileSync } = require('node:child_process'); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + const version = JSON.parse(fs.readFileSync('package.json', 'utf8')).version; + const tag = `v${version}-repair.${context.runId}.${process.env.GITHUB_RUN_ATTEMPT}`; + // createRef rejects an existing tag; this workflow never updates refs. + await github.rest.git.createRef({ ...context.repo, ref: `refs/tags/${tag}`, sha }); + core.setOutput('tag', tag); + core.setOutput('sha', sha); + const { data: release } = await github.rest.repos.createRelease({ + ...context.repo, tag_name: tag, target_commitish: sha, + name: `Controlled publication test ${tag}`, prerelease: true, make_latest: 'false', + body: `Disposable publication verification for commit ${sha}.`, + }); + await github.rest.repos.uploadReleaseAsset({ + ...context.repo, release_id: release.id, name: 'action.tar.gz', + data: fs.readFileSync('action.tar.gz'), + }); + const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag }); + const { data: published } = await github.rest.repos.getReleaseByTag({ ...context.repo, tag }); + if (commit.sha !== sha || published.draft || !published.prerelease || + published.tag_name !== tag || !published.body.includes(sha) || + published.assets.length !== 1 || published.assets[0].name !== 'action.tar.gz') { + throw new Error('Published test release differs from the requested artifact and revision.'); + } + - name: Verify the published artifact byte for byte + env: + GH_TOKEN: ${{ github.token }} + TEST_TAG: ${{ steps.publish.outputs.tag }} + run: | + gh release download "$TEST_TAG" --repo "$GITHUB_REPOSITORY" --pattern action.tar.gz --dir downloaded + cmp action.tar.gz downloaded/action.tar.gz + + cleanup: + if: always() && needs.publish.outputs.tag != '' + needs: publish + uses: ./.github/workflows/pull_request_cleanup_tags_and_releases.yml + with: + tag: ${{ needs.publish.outputs.tag }} + expected_sha: ${{ needs.publish.outputs.sha }} + + verify-cleanup: + needs: [publish, cleanup] + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/github-script@v8 + env: + TEST_TAG: ${{ needs.publish.outputs.tag }} + with: + script: | + const tag = process.env.TEST_TAG; + for (const lookup of [ + () => github.rest.repos.getReleaseByTag({ ...context.repo, tag }), + () => github.rest.git.getRef({ ...context.repo, ref: `tags/${tag}` }), + ]) { + try { await lookup(); } + catch (error) { if (error.status === 404) continue; throw error; } + throw new Error(`Controlled test artifact ${tag} remains after cleanup.`); + } + core.info(`Verified exact test release and tag ${tag} are absent.`);