From e3a2e19ac367094a0fbdb4135f5bafe44e8c3f0c Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sat, 19 Sep 2026 22:50:50 -0600 Subject: [PATCH 1/2] ci: prepare temporary retired-package cleanup workflow Adds the owner-approved TEMPORARY manual-only workflow .github/workflows/delete-retired-sms-package.yml for the retired container package opencode-sms-bridge (source removed in d833bb6). workflow_dispatch with no inputs; runs only on makeitworkcloud/images@refs/heads/main; default GITHUB_TOKEN with contents:read and packages:write (this publishing repository holds admin on the package). The script resolves the package through the paginated org inventory, verifies id, name, package_type, and the exact repository association fail-closed before deleting the entire package, then confirms the result with a 404 GET plus an inventory relist. Absence after a successful inventory GET is success, so the run is idempotent on re-dispatch. actions/github-script is pinned to the verified v9.0.0 commit 3a2844b7e9c422d3c10d287c895573f7108da1b3 already used by buildah.yml. No dispatch, merge, or package deletion happens in this commit; the buildah publication workflow and its triggers are unchanged. Public packages over 5000 downloads are refused by GitHub and not bypassed; deleted packages remain restorable for 30 days. Remove this workflow after the confirmed cleanup run. --- .../workflows/delete-retired-sms-package.yml | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .github/workflows/delete-retired-sms-package.yml diff --git a/.github/workflows/delete-retired-sms-package.yml b/.github/workflows/delete-retired-sms-package.yml new file mode 100644 index 0000000..a6dd8df --- /dev/null +++ b/.github/workflows/delete-retired-sms-package.yml @@ -0,0 +1,129 @@ +--- +# TEMPORARY owner-approved cleanup workflow. Deletes the retired container +# package ghcr.io/makeitworkcloud/opencode-sms-bridge (image source removed +# in d833bb6). Manual workflow_dispatch only, main only, exact repo only. +# Remove this workflow after the confirmed cleanup run. +# +# Known limits, deliberately not bypassed: +# - GitHub refuses REST deletion of a public package when any version has +# more than 5000 downloads; the run fails and GitHub support is the path. +# - GITHUB_TOKEN deletion works because this repository's publishing +# workflow created the package (publisher repo holds admin by default). +# - A deleted package stays restorable for 30 days via the packages API. +name: delete-retired-sms-package + +on: + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: ${{ github.repository }}-delete-retired-sms-package + cancel-in-progress: false + +jobs: + delete: + if: github.repository == 'makeitworkcloud/images' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify and delete package opencode-sms-bridge + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const owner = 'makeitworkcloud'; + const expectedRepo = 'makeitworkcloud/images'; + const expectedName = 'opencode-sms-bridge'; + const packageType = 'container'; + // Multi-segment container names must be URL-encoded in the API + // path; octokit does not encode path parameters itself. + const encodedName = encodeURIComponent(expectedName); + + // Fail-closed guard mirroring the job-level condition. + const repoFull = `${context.repo.owner}/${context.repo.repo}`; + if (repoFull !== expectedRepo || context.ref !== 'refs/heads/main') { + core.setFailed(`refusing: ${repoFull}@${context.ref} is not ${expectedRepo}@refs/heads/main`); + return; + } + + // 1) Paginated org inventory GET. A 403 fails the step; only a + // successful inventory may conclude the package is absent. + const inventory = await github.paginate( + github.rest.packages.listPackagesForOrganization, + { org: owner, package_type: packageType, per_page: 100 }, + ); + core.info(`org ${packageType} inventory: ${inventory.length} package(s)`); + const listed = inventory.find( + (p) => p.name === expectedName && p.package_type === packageType, + ); + if (!listed) { + // Idempotent re-run: absent after a successful inventory GET. + core.notice(`package "${expectedName}" is absent from the org inventory; nothing to do`); + return; + } + + // 2) Authoritative metadata. Verify before any DELETE; fail + // closed on any mismatch or missing repository association. + const meta = ( + await github.rest.packages.getPackageForOrganization({ + org: owner, + package_type: packageType, + package_name: encodedName, + }) + ).data; + const repoLabel = meta.repository ? meta.repository.full_name : 'none'; + core.info(`resolved id=${meta.id} name=${meta.name} type=${meta.package_type}`); + core.info(`visibility=${meta.visibility} version_count=${meta.version_count} repository=${repoLabel}`); + const problems = []; + if (meta.id !== listed.id) problems.push(`inventory id ${listed.id} != metadata id ${meta.id}`); + if (meta.name !== expectedName) problems.push(`name "${meta.name}" != "${expectedName}"`); + if (meta.package_type !== packageType) problems.push(`type "${meta.package_type}" != "${packageType}"`); + if (!meta.repository) problems.push('package has no repository association'); + else if (meta.repository.full_name !== expectedRepo) { + problems.push(`repository association ${meta.repository.full_name} != ${expectedRepo}`); + } + if (problems.length > 0) { + core.setFailed(`verification failed, refusing to delete: ${problems.join('; ')}`); + return; + } + + // 3) Count real versions from the API before deleting. + const versions = await github.paginate( + github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, + { org: owner, package_type: packageType, package_name: encodedName, per_page: 100 }, + ); + core.info(`versions present: ${versions.length} (sample id=${versions.length > 0 ? versions[0].id : 'none'})`); + + // 4) Delete the ENTIRE package, not individual versions. + await github.rest.packages.deletePackageForOrganization({ + org: owner, + package_type: packageType, + package_name: encodedName, + }); + core.info(`DELETE accepted for "${expectedName}" (id=${meta.id})`); + + // 5) Verify with the same token: GET must 404 and the relisted + // inventory must not contain the package. + try { + await github.rest.packages.getPackageForOrganization({ + org: owner, + package_type: packageType, + package_name: encodedName, + }); + core.setFailed('package is still readable after deletion'); + return; + } catch (postError) { + if (postError.status !== 404) throw postError; + core.info('post-delete GET returned 404 as expected'); + } + const relisted = await github.paginate( + github.rest.packages.listPackagesForOrganization, + { org: owner, package_type: packageType, per_page: 100 }, + ); + if (relisted.some((p) => p.name === expectedName)) { + core.setFailed('package is still listed in the org inventory after deletion'); + return; + } + core.notice(`deleted package "${expectedName}" (id=${meta.id}, ${versions.length} version(s)); relist confirms absence`); From b77c601de613b1564e4125beb196e0a45b7c054f Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sat, 19 Sep 2026 23:17:59 -0600 Subject: [PATCH 2/2] ci: pass raw package name and self-check route encoding Adversarial review confirmed the structured Octokit REST methods encode route parameters themselves: octokit/endpoint.js builds URLs through its url-template util, whose encodeValue() applies encodeURIComponent (encodeUnreserved) to simple {name} placeholders such as {package_name} (source: octokit/endpoint.js src/util/url-template.ts). Passing a pre-encoded name would double-encode the slash (for example charts%252F...). Remove the encodedName variable and its incorrect comment and pass the raw package name to the metadata GET, the version list, the package DELETE, and the post-delete GET. Add a pre-request self-check that fails before any API call unless github.request.endpoint('GET /orgs/{org}/packages/{package_type}/{package_name}', {...}) builds a URL whose pathname, read via new URL(), equals the singly encoded /orgs//packages/container/ path. Only non-secret path names appear in the failure message; no tokens are logged. The actions/github-script pin is unchanged. No dispatch, merge, or live API execution happens in this commit. --- .../workflows/delete-retired-sms-package.yml | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/workflows/delete-retired-sms-package.yml b/.github/workflows/delete-retired-sms-package.yml index a6dd8df..38b438c 100644 --- a/.github/workflows/delete-retired-sms-package.yml +++ b/.github/workflows/delete-retired-sms-package.yml @@ -37,9 +37,6 @@ jobs: const expectedRepo = 'makeitworkcloud/images'; const expectedName = 'opencode-sms-bridge'; const packageType = 'container'; - // Multi-segment container names must be URL-encoded in the API - // path; octokit does not encode path parameters itself. - const encodedName = encodeURIComponent(expectedName); // Fail-closed guard mirroring the job-level condition. const repoFull = `${context.repo.owner}/${context.repo.repo}`; @@ -48,6 +45,22 @@ jobs: return; } + // 0) Pre-request self-check BEFORE any API call: structured REST + // methods encode route parameters themselves (octokit + // endpoint.js url-template applies encodeURIComponent to + // simple {name} placeholders), so the raw name must build a + // URL with the slashed name encoded exactly once. + const route = github.request.endpoint( + 'GET /orgs/{org}/packages/{package_type}/{package_name}', + { org: owner, package_type: packageType, package_name: expectedName }, + ); + const expectedPath = `/orgs/${owner}/packages/${packageType}/${encodeURIComponent(expectedName)}`; + const builtPath = new URL(route.url).pathname; + if (builtPath !== expectedPath) { + core.setFailed(`route encoding check failed: ${builtPath} != ${expectedPath}`); + return; + } + // 1) Paginated org inventory GET. A 403 fails the step; only a // successful inventory may conclude the package is absent. const inventory = await github.paginate( @@ -70,7 +83,7 @@ jobs: await github.rest.packages.getPackageForOrganization({ org: owner, package_type: packageType, - package_name: encodedName, + package_name: expectedName, }) ).data; const repoLabel = meta.repository ? meta.repository.full_name : 'none'; @@ -92,7 +105,7 @@ jobs: // 3) Count real versions from the API before deleting. const versions = await github.paginate( github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, - { org: owner, package_type: packageType, package_name: encodedName, per_page: 100 }, + { org: owner, package_type: packageType, package_name: expectedName, per_page: 100 }, ); core.info(`versions present: ${versions.length} (sample id=${versions.length > 0 ? versions[0].id : 'none'})`); @@ -100,7 +113,7 @@ jobs: await github.rest.packages.deletePackageForOrganization({ org: owner, package_type: packageType, - package_name: encodedName, + package_name: expectedName, }); core.info(`DELETE accepted for "${expectedName}" (id=${meta.id})`); @@ -110,7 +123,7 @@ jobs: await github.rest.packages.getPackageForOrganization({ org: owner, package_type: packageType, - package_name: encodedName, + package_name: expectedName, }); core.setFailed('package is still readable after deletion'); return;