From b76c9eba18bd5962de1dc73c92e78d51faabaa09 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 OCI chart package charts/opencode-sms-bridge at ghcr.io/makeitworkcloud/charts/opencode-sms-bridge (chart source removed in b7f85df8). workflow_dispatch with no inputs; runs only on makeitworkcloud/charts@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 (slashed container name URL-encoded for the API path), 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 helm.yml. No dispatch, merge, or package deletion happens in this commit; the helm 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 | 130 ++++++++++++++++++ 1 file changed, 130 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..b7b8ab3 --- /dev/null +++ b/.github/workflows/delete-retired-sms-package.yml @@ -0,0 +1,130 @@ +--- +# TEMPORARY owner-approved cleanup workflow. Deletes the retired OCI chart +# package ghcr.io/makeitworkcloud/charts/opencode-sms-bridge (chart source +# removed in b7f85df8). 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/charts' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify and delete package charts/opencode-sms-bridge + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const owner = 'makeitworkcloud'; + const expectedRepo = 'makeitworkcloud/charts'; + const expectedName = 'charts/opencode-sms-bridge'; + const packageType = 'container'; + // Multi-segment container names must be URL-encoded in the API + // path (charts%2Fopencode-sms-bridge); 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 3404a228fc01e41d691aacf05af39ce14e2fab80 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 in charts/opencode-sms-bridge as charts%252Fopencode-sms-bridge and the calls would 404. 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 | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/delete-retired-sms-package.yml b/.github/workflows/delete-retired-sms-package.yml index b7b8ab3..2e3ae5f 100644 --- a/.github/workflows/delete-retired-sms-package.yml +++ b/.github/workflows/delete-retired-sms-package.yml @@ -37,10 +37,6 @@ jobs: const expectedRepo = 'makeitworkcloud/charts'; const expectedName = 'charts/opencode-sms-bridge'; const packageType = 'container'; - // Multi-segment container names must be URL-encoded in the API - // path (charts%2Fopencode-sms-bridge); 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}`; @@ -49,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( @@ -71,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'; @@ -93,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'})`); @@ -101,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})`); @@ -111,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;