diff --git a/.github/workflows/delete-retired-sms-package.yml b/.github/workflows/delete-retired-sms-package.yml new file mode 100644 index 0000000..2e3ae5f --- /dev/null +++ b/.github/workflows/delete-retired-sms-package.yml @@ -0,0 +1,142 @@ +--- +# 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'; + + // 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; + } + + // 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( + 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: expectedName, + }) + ).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: expectedName, 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: expectedName, + }); + 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: expectedName, + }); + 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`);