diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f528a76 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.venv +**/.venv +**/__pycache__ +**/node_modules +**/dist +**/.env* +.dev.vars +.wrangler +tasks +docs diff --git a/.github/pages-preview-closed/_headers b/.github/pages-preview-closed/_headers deleted file mode 100644 index 4d77190..0000000 --- a/.github/pages-preview-closed/_headers +++ /dev/null @@ -1,6 +0,0 @@ -/* - X-Robots-Tag: noindex, nofollow - Cache-Control: no-store - Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none' - Referrer-Policy: no-referrer - X-Content-Type-Options: nosniff diff --git a/.github/pages-preview-closed/index.html b/.github/pages-preview-closed/index.html deleted file mode 100644 index 00f666c..0000000 --- a/.github/pages-preview-closed/index.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - Preview closed · InKCre Extension Registry - - - -
-

Preview closed

-

This pull-request preview is no longer active.

-
- - diff --git a/.github/pages-preview/_headers b/.github/pages-preview/_headers deleted file mode 100644 index 4d77190..0000000 --- a/.github/pages-preview/_headers +++ /dev/null @@ -1,6 +0,0 @@ -/* - X-Robots-Tag: noindex, nofollow - Cache-Control: no-store - Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none' - Referrer-Policy: no-referrer - X-Content-Type-Options: nosniff diff --git a/.github/pages-preview/extensions.json b/.github/pages-preview/extensions.json deleted file mode 100644 index 5e6378c..0000000 --- a/.github/pages-preview/extensions.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "schema_version": 1, - "extensions": [ - { "name": "inkcre/github", "nickname": "GitHub" }, - { "name": "inkcre/learn-english", "nickname": "Learn English" }, - { "name": "inkcre/mail", "nickname": "Mail" }, - { "name": "inkcre/rss", "nickname": "RSS/Atom Feeds" }, - { "name": "inkcre/telegram", "nickname": "Telegram" }, - { "name": "inkcre/twitter", "nickname": "Twitter" } - ] -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3efa097..e264918 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,20 @@ jobs: name: ext-reg checks runs-on: ubuntu-latest timeout-minutes: 20 + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: registry + POSTGRES_PASSWORD: registry-check + POSTGRES_DB: registry_check + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U registry -d registry_check" + --health-interval 5s --health-timeout 5s --health-retries 12 + env: + REGISTRY_TEST_DATABASE_URL: postgres://registry:registry-check@localhost:5432/registry_check steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -60,3 +74,28 @@ jobs: - name: Run repository contract run: pnpm check + + - name: Enforce append-only deployed migration history + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git fetch --no-tags origin "$BASE_SHA" + git diff --exit-code --diff-filter=DMRT "$BASE_SHA" HEAD -- migrations src/inkcre_extension_registry/migrations + + - name: Build the single CPython service image + run: docker build --build-arg SOURCE_REVISION="$GITHUB_SHA" --tag registry-check . + + - name: Verify the installed image serves HTTP and the migrated database + run: | + docker run --detach --rm --name registry-image-check --network host \ + --env DATABASE_URL="$REGISTRY_TEST_DATABASE_URL" \ + --env PUBLIC_ORIGIN=http://localhost:8000 \ + --env S3_ENDPOINT_URL=http://localhost:9000 --env S3_BUCKET=unused \ + --env AWS_ACCESS_KEY_ID=testing --env AWS_SECRET_ACCESS_KEY=testing \ + registry-check + trap 'docker logs registry-image-check; docker stop registry-image-check' EXIT + curl --fail --silent --show-error --retry 20 --retry-connrefused --retry-delay 1 \ + http://localhost:8000/livez | jq -e '.status == "ok" and .revision == env.GITHUB_SHA' + curl --fail --silent --show-error http://localhost:8000/v1/extensions | jq -e '. == []' + curl --fail --silent --show-error http://localhost:8000/publish | grep -F 'Extension Registry' diff --git a/.github/workflows/pages-preview-cleanup.yml b/.github/workflows/pages-preview-cleanup.yml deleted file mode 100644 index 296e970..0000000 --- a/.github/workflows/pages-preview-cleanup.yml +++ /dev/null @@ -1,165 +0,0 @@ -name: Pages preview cleanup - -on: - pull_request_target: - branches: - - main - types: - - closed - workflow_dispatch: - inputs: - pull_number: - description: Closed pull request whose preview should be retired - required: true - type: number - -permissions: - contents: read - pull-requests: read - -jobs: - cleanup: - name: ext-reg preview cleanup - if: >- - github.event_name == 'workflow_dispatch' || - (github.event.pull_request.base.ref == 'main' && - github.event.pull_request.base.repo.full_name == github.repository && - github.event.pull_request.head.repo.full_name == github.repository) - concurrency: - group: pages-preview-ext-reg-${{ github.event.pull_request.number || inputs.pull_number }} - cancel-in-progress: false - permissions: - contents: read - deployments: write - pull-requests: read - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: - name: preview - url: ${{ steps.tombstone.outputs.pages-deployment-alias-url }} - steps: - - name: Resolve the closed pull request - id: identity - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - REQUESTED_PULL_NUMBER: ${{ github.event.pull_request.number || inputs.pull_number }} - with: - script: | - const pullNumber = Number(process.env.REQUESTED_PULL_NUMBER) - if (!Number.isInteger(pullNumber) || pullNumber < 1) { - core.setFailed('A positive pull-request number is required.') - return - } - const { data: pull } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullNumber, - }) - if ( - pull.state !== 'closed' || - pull.base.ref !== 'main' || - pull.base.repo.full_name !== `${context.repo.owner}/${context.repo.repo}` || - pull.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}` - ) { - core.setFailed('Cleanup only accepts a closed same-repository pull request targeting main.') - return - } - core.setOutput('branch', `preview/ext-reg/pr-${pullNumber}`) - - - name: Checkout the trusted cleanup controller - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.workflow_sha }} - path: controller - persist-credentials: false - - - name: Install pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - version: 11.11.0 - - - name: Set up Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version-file: controller/package.json - cache: pnpm - cache-dependency-path: controller/pnpm-lock.yaml - - - name: Install frozen Wrangler - run: pnpm --dir controller install --frozen-lockfile - - - name: Replace the stable alias with the trusted tombstone - id: tombstone - uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 - with: - apiToken: ${{ secrets.CLOUDFLARE_PAGES_API_TOKEN }} - accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - packageManager: npm - wranglerVersion: 4.120.0 - workingDirectory: controller/.github/pages-preview-closed - command: >- - pages deploy . - --project-name=${{ vars.REGISTRY_UI_PREVIEW_PAGES_PROJECT }} - --branch=${{ steps.identity.outputs.branch }} - --commit-hash=${{ github.workflow_sha }} - --commit-dirty=false - - - name: Verify the closed Preview alias - env: - PREVIEW_URL: ${{ steps.tombstone.outputs.pages-deployment-alias-url }} - run: | - test -n "$PREVIEW_URL" - verified=0 - for attempt in {1..12}; do - if curl --fail --silent --show-error \ - --connect-timeout 5 \ - --max-time 10 \ - --dump-header .pages-preview-closed-headers \ - --output .pages-preview-closed-body \ - "$PREVIEW_URL/" && - grep -Fq 'This pull-request preview is no longer active.' \ - .pages-preview-closed-body && - grep -iq '^x-robots-tag: noindex, nofollow' \ - .pages-preview-closed-headers && - grep -iq '^cache-control: no-store' \ - .pages-preview-closed-headers; then - verified=1 - break - fi - sleep 2 - done - test "$verified" -eq 1 - - - name: Delete older exact-branch deployments - env: - CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PAGES_API_TOKEN }} - KEEP_DEPLOYMENT_ID: ${{ steps.tombstone.outputs.pages-deployment-id }} - PAGES_BRANCH: ${{ steps.identity.outputs.branch }} - PAGES_PROJECT: ${{ vars.REGISTRY_UI_PREVIEW_PAGES_PROJECT }} - run: | - test -n "$CLOUDFLARE_ACCOUNT_ID" - test -n "$CLOUDFLARE_API_TOKEN" - test -n "$KEEP_DEPLOYMENT_ID" - test -n "$PAGES_PROJECT" - pnpm --dir controller exec wrangler pages deployment list \ - --project-name "$PAGES_PROJECT" \ - --environment preview \ - --json > .pages-preview-deployments.json - jq -e \ - --arg branch "$PAGES_BRANCH" \ - --arg keep "$KEEP_DEPLOYMENT_ID" \ - 'any(.[]; .Branch == $branch and .Id == $keep)' \ - .pages-preview-deployments.json - jq -r \ - --arg branch "$PAGES_BRANCH" \ - --arg keep "$KEEP_DEPLOYMENT_ID" \ - '.[] | select(.Branch == $branch and .Id != $keep) | .Id' \ - .pages-preview-deployments.json | \ - while IFS= read -r deployment_id; do - test -n "$deployment_id" - pnpm --dir controller exec wrangler pages deployment delete \ - "$deployment_id" \ - --project-name "$PAGES_PROJECT" \ - --force - done diff --git a/.github/workflows/pages-preview.yml b/.github/workflows/pages-preview.yml deleted file mode 100644 index aaf5260..0000000 --- a/.github/workflows/pages-preview.yml +++ /dev/null @@ -1,192 +0,0 @@ -name: Pages preview - -on: - workflow_run: - workflows: - - Registry checks - types: - - completed - -permissions: - actions: read - contents: read - pull-requests: read - -jobs: - identity: - name: Resolve trusted preview identity - if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.head_repository.full_name == github.repository - runs-on: ubuntu-latest - outputs: - branch: ${{ steps.identity.outputs.branch }} - head_sha: ${{ steps.identity.outputs.head_sha }} - pull_number: ${{ steps.identity.outputs.pull_number }} - steps: - - name: Resolve exact internal pull request - id: identity - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const runId = context.payload.workflow_run.id - const { data: run } = await github.rest.actions.getWorkflowRun({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: runId, - }) - if (run.name !== 'Registry checks' || run.path !== '.github/workflows/ci.yml') { - core.setFailed('Preview must be orchestrated by the trusted Registry checks workflow.') - return - } - if (run.conclusion !== 'success' || run.event !== 'pull_request') { - core.setFailed('Preview delivery requires a successful pull-request check run.') - return - } - if (run.head_repository?.full_name !== `${context.repo.owner}/${context.repo.repo}`) { - core.setFailed('Fork pull requests are not eligible for preview delivery.') - return - } - - const pulls = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: run.head_sha, - }) - const eligible = pulls.data.filter((pull) => - pull.state === 'open' && - pull.base.ref === 'main' && - pull.head.sha === run.head_sha && - pull.head.repo?.full_name === `${context.repo.owner}/${context.repo.repo}` - ) - if (eligible.length !== 1) { - core.setFailed('Run must be the exact head of one eligible internal pull request.') - return - } - - const pull = eligible[0] - core.setOutput('branch', `preview/ext-reg/pr-${pull.number}`) - core.setOutput('head_sha', run.head_sha) - core.setOutput('pull_number', String(pull.number)) - - deploy: - name: ext-reg preview - needs: identity - concurrency: - group: pages-preview-ext-reg-${{ needs.identity.outputs.pull_number }} - cancel-in-progress: true - permissions: - contents: read - deployments: write - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: - name: preview - url: ${{ steps.pages.outputs.pages-deployment-alias-url }} - steps: - - name: Checkout the trusted preview controller - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.workflow_sha }} - path: controller - persist-credentials: false - - - name: Checkout the exact pull-request head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.identity.outputs.head_sha }} - path: candidate - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version-file: candidate/.python-version - - - name: Install PDM - run: python -m pip install pdm==2.28.0 - - - name: Install frozen candidate dependencies - working-directory: candidate - run: pdm install --frozen-lockfile - - - name: Set up Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version-file: controller/package.json - - - name: Build the exact-head Extension-list preview - working-directory: candidate - run: >- - pdm run python scripts/build_ui_preview.py - --fixture .github/pages-preview/extensions.json - --output "$GITHUB_WORKSPACE/.pages-preview/candidate" - --api-origin https://registry.inkcre.dev - - - name: Prepare the bounded static deployment - env: - PREVIEW_HEAD_SHA: ${{ needs.identity.outputs.head_sha }} - PULL_NUMBER: ${{ needs.identity.outputs.pull_number }} - run: | - mkdir .pages-preview/dist - cp .pages-preview/candidate/index.html .pages-preview/dist/index.html - cp controller/.github/pages-preview/_headers .pages-preview/dist/_headers - jq -n \ - --argjson pull_request "$PULL_NUMBER" \ - --arg source_sha "$PREVIEW_HEAD_SHA" \ - '{schema_version: 1, pull_request: $pull_request, source_sha: $source_sha}' \ - > .pages-preview/dist/preview.json - - - name: Deploy to the isolated Pages preview branch - id: pages - uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 - with: - apiToken: ${{ secrets.CLOUDFLARE_PAGES_API_TOKEN }} - accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - packageManager: npm - wranglerVersion: 4.120.0 - workingDirectory: .pages-preview - command: >- - pages deploy dist - --project-name=${{ vars.REGISTRY_UI_PREVIEW_PAGES_PROJECT }} - --branch=${{ needs.identity.outputs.branch }} - --commit-hash=${{ needs.identity.outputs.head_sha }} - --commit-dirty=false - - - name: Smoke exact and stable Preview URLs - env: - ALIAS_URL: ${{ steps.pages.outputs.pages-deployment-alias-url }} - DEPLOYMENT_URL: ${{ steps.pages.outputs.deployment-url }} - PREVIEW_HEAD_SHA: ${{ needs.identity.outputs.head_sha }} - PULL_NUMBER: ${{ needs.identity.outputs.pull_number }} - run: | - for preview_url in "$DEPLOYMENT_URL" "$ALIAS_URL"; do - test -n "$preview_url" - curl --fail --silent --show-error --retry 6 --retry-all-errors \ - "$preview_url/preview.json" | \ - jq -e \ - --arg source_sha "$PREVIEW_HEAD_SHA" \ - --argjson pull_request "$PULL_NUMBER" \ - '.schema_version == 1 and .source_sha == $source_sha and .pull_request == $pull_request' - curl --fail --silent --show-error --retry 6 --retry-all-errors \ - --dump-header .pages-preview/headers \ - --output .pages-preview/index.html \ - "$preview_url/" - grep -i '^content-type: text/html' .pages-preview/headers - grep -i '^x-robots-tag: noindex, nofollow' .pages-preview/headers - grep -i '^content-security-policy:' .pages-preview/headers - done - - - name: Record Preview evidence - env: - PREVIEW_URL: ${{ steps.pages.outputs.pages-deployment-alias-url }} - run: | - { - echo '## Extension Registry PR Preview' - echo - echo "- Source: \`${{ needs.identity.outputs.head_sha }}\`" - echo "- Preview: $PREVIEW_URL" - echo '- Surface: static Extension list only' - echo '- Registry API and production data: unchanged' - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 34033e7..2506c10 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -31,10 +31,19 @@ jobs: environment: name: production url: https://registry.inkcre.dev + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: registry + POSTGRES_PASSWORD: registry-check + POSTGRES_DB: registry_check + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U registry -d registry_check" + --health-interval 5s --health-timeout 5s --health-retries 12 env: - CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - REGISTRY_ORIGIN: https://registry.inkcre.dev + REGISTRY_TEST_DATABASE_URL: postgres://registry:registry-check@localhost:5432/registry_check SOURCE_SHA: ${{ inputs.source_sha }} steps: - name: Checkout verified source @@ -75,45 +84,36 @@ jobs: - name: Verify exact delivery source run: pnpm check - - name: Apply native production schema - if: inputs.operation == 'deploy' - run: pnpm exec wrangler d1 migrations apply DB --remote --config wrangler.jsonc + - name: Build the same CPython service artifact used by previews + run: docker build --build-arg SOURCE_REVISION="$SOURCE_SHA" --tag registry-production . - - name: Deploy exact Worker and bindings + - name: Install the pinned Heroku CLI if: inputs.operation == 'deploy' - run: | - pdm run pywrangler deploy \ - --config wrangler.jsonc \ - --message "Native Registry production at $SOURCE_SHA" \ - --strict + run: npm install --global heroku@11.8.1 - - name: Smoke anonymous production reads + - name: Migrate and release the authorized production app + if: inputs.operation == 'deploy' + env: + HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} + HEROKU_APP_NAME: ${{ vars.HEROKU_APP_NAME }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }} + S3_ENDPOINT_URL: ${{ vars.S3_ENDPOINT_URL }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} run: | - curl --fail --silent --show-error --retry 12 --retry-all-errors \ - "$REGISTRY_ORIGIN/livez" | jq -e '.status == "ok"' - curl --fail --silent --show-error --retry 12 --retry-all-errors \ - "$REGISTRY_ORIGIN/" | grep -F 'Extension Registry' - curl --fail --silent --show-error --retry 12 --retry-all-errors \ - "$REGISTRY_ORIGIN/v1/extensions" | jq -e 'type == "array"' - curl --fail --silent --show-error --retry 12 --retry-all-errors \ - -H 'Accept: application/vnd.pypi.simple.v1+json' \ - "$REGISTRY_ORIGIN/simple/" | jq -e '.meta["api-version"] == "1.1"' + current_main=$(git ls-remote origin refs/heads/main | cut -f1) + test "$current_main" = "$SOURCE_SHA" + pdm run python scripts/registry_production.py --image registry-production - - name: Record production evidence + - name: Record delivery boundary + env: + OPERATION: ${{ inputs.operation }} run: | { - echo '## Extension Registry production authority' - echo - echo "- Source: \`$SOURCE_SHA\`" - echo "- Operation: \`${{ inputs.operation }}\`" - echo "- Origin: $REGISTRY_ORIGIN" - echo '- D1: inkcre-extension-registry-production-v2' - echo '- R2: inkcre-extension-registry-production-v2' - echo '- Full repository contract and anonymous smoke: passed' - if [ "${{ inputs.operation }}" = 'deploy' ]; then - echo '- Checked migration and exact Worker deployment: passed' - else - echo '- Remote mutation: none (verification only)' - fi - echo '- Rollback: previously verified compatible native-v2 Worker Version' + echo '## Registry CPython 交付' + echo "- 来源:$SOURCE_SHA" + echo "- 操作:$OPERATION" + echo '- 数据导入、旧写入暂停和域名切换按 production-registry.md 单独执行。' } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/registry-preview-cleanup.yml b/.github/workflows/registry-preview-cleanup.yml new file mode 100644 index 0000000..c5a78f6 --- /dev/null +++ b/.github/workflows/registry-preview-cleanup.yml @@ -0,0 +1,113 @@ +name: Registry preview cleanup + +on: + pull_request_target: + branches: + - main + types: + - closed + workflow_dispatch: + inputs: + pull_number: + description: Closed pull request whose preview should be retired + required: true + type: number + +permissions: + contents: read + pull-requests: read + +jobs: + cleanup: + name: ext-reg preview cleanup + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.base.ref == 'main' && + github.event.pull_request.base.repo.full_name == github.repository && + github.event.pull_request.head.repo.full_name == github.repository) + concurrency: + group: registry-preview-ext-reg-${{ github.event.pull_request.number || inputs.pull_number }} + cancel-in-progress: false + permissions: + contents: read + deployments: write + pull-requests: read + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: preview + steps: + - name: Checkout the trusted cleanup controller + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version-file: .python-version + - name: Install trusted controller tooling + run: | + python -m pip install pdm==2.28.0 + pdm install --frozen-lockfile + npm install --global heroku@11.8.1 + + - name: Install pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: package.json + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version-file: .python-version + - name: Install frozen trusted tooling + run: | + python -m pip install pdm==2.28.0 + pdm install --frozen-lockfile + pnpm install --frozen-lockfile + - name: Resolve the closed pull request + id: identity + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REQUESTED_PULL_NUMBER: ${{ github.event.pull_request.number || inputs.pull_number }} + with: + script: | + const pullNumber = Number(process.env.REQUESTED_PULL_NUMBER) + if (!Number.isInteger(pullNumber) || pullNumber < 1) { + core.setFailed('A positive pull-request number is required.') + return + } + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullNumber, + }) + if ( + pull.state !== 'closed' || + pull.base.ref !== 'main' || + pull.base.repo.full_name !== `${context.repo.owner}/${context.repo.repo}` || + pull.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}` + ) { + core.setFailed('Cleanup only accepts a closed same-repository pull request targeting main.') + return + } + core.setOutput('pull_number', String(pullNumber)) + + - name: Retire the closed PR app, database and bucket + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_PREVIEW_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }} + HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} + NEON_API_KEY: ${{ secrets.NEON_API_KEY }} + NEON_PROJECT_ID: ${{ vars.NEON_PROJECT_ID }} + PULL_NUMBER: ${{ steps.identity.outputs.pull_number }} + run: pdm run python scripts/registry_preview.py retire --pull-number "$PULL_NUMBER" diff --git a/.github/workflows/registry-preview.yml b/.github/workflows/registry-preview.yml new file mode 100644 index 0000000..8d19c64 --- /dev/null +++ b/.github/workflows/registry-preview.yml @@ -0,0 +1,203 @@ +name: Registry preview + +on: + workflow_run: + workflows: + - Registry checks + types: + - completed + +permissions: + actions: read + contents: read + pull-requests: read + +jobs: + identity: + name: Resolve trusted preview identity + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + outputs: + head_sha: ${{ steps.identity.outputs.head_sha }} + pull_number: ${{ steps.identity.outputs.pull_number }} + steps: + - name: Resolve exact internal pull request + id: identity + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const runId = context.payload.workflow_run.id + const { data: run } = await github.rest.actions.getWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + }) + if (run.name !== 'Registry checks' || run.path !== '.github/workflows/ci.yml') { + core.setFailed('Preview must be orchestrated by the trusted Registry checks workflow.') + return + } + if (run.conclusion !== 'success' || run.event !== 'pull_request') { + core.setFailed('Preview delivery requires a successful pull-request check run.') + return + } + if (run.head_repository?.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + core.setFailed('Fork pull requests are not eligible for preview delivery.') + return + } + + const pulls = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: run.head_sha, + }) + const eligible = pulls.data.filter((pull) => + pull.state === 'open' && + pull.base.ref === 'main' && + pull.head.sha === run.head_sha && + pull.head.repo?.full_name === `${context.repo.owner}/${context.repo.repo}` + ) + if (eligible.length !== 1) { + core.setFailed('Run must be the exact head of one eligible internal pull request.') + return + } + + const pull = eligible[0] + core.setOutput('head_sha', run.head_sha) + core.setOutput('pull_number', String(pull.number)) + + build: + name: Build exact-head CPython image + needs: identity + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout exact PR head without deployment authority + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.identity.outputs.head_sha }} + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version-file: .python-version + - name: Install PDM + run: python -m pip install pdm==2.28.0 + - name: Install pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: package.json + cache: pnpm + - name: Build the single service image without deployment credentials + env: + SOURCE_SHA: ${{ needs.identity.outputs.head_sha }} + run: | + docker build --build-arg SOURCE_REVISION="$SOURCE_SHA" --tag registry-preview . + docker save registry-preview --output "$RUNNER_TEMP/registry-image.tar" + - name: Transfer the image to a separate delivery runner + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: registry-image + path: ${{ runner.temp }}/registry-image.tar + retention-days: 1 + if-no-files-found: error + + deploy: + name: ext-reg preview + needs: [identity, build] + concurrency: + group: registry-preview-ext-reg-${{ needs.identity.outputs.pull_number }} + cancel-in-progress: false + permissions: + contents: read + pull-requests: read + deployments: write + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: + name: preview + url: ${{ steps.deploy.outputs.url }} + steps: + - name: Checkout only the trusted controller + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version-file: .python-version + - name: Install PDM + run: python -m pip install pdm==2.28.0 + - name: Install pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: package.json + cache: pnpm + - name: Install frozen trusted tooling + run: | + pdm install --frozen-lockfile + pnpm install --frozen-lockfile + - name: Install the pinned Heroku CLI + run: npm install --global heroku@11.8.1 + - name: Download this run's service image + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: registry-image + path: ${{ runner.temp }}/registry-image + - name: Recheck PR identity after build and concurrency queue + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PULL_NUMBER: ${{ needs.identity.outputs.pull_number }} + SOURCE_SHA: ${{ needs.identity.outputs.head_sha }} + with: + script: | + const { data: pull } = await github.rest.pulls.get({ + ...context.repo, pull_number: Number(process.env.PULL_NUMBER), + }) + if (pull.state !== 'open' || pull.base.ref !== 'main' || + pull.head.repo?.full_name !== process.env.GITHUB_REPOSITORY || + pull.head.sha !== process.env.SOURCE_SHA) { + throw new Error('PR closed or moved; no preview mutation is allowed.') + } + - name: Apply migrations and deploy the isolated service + id: deploy + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_PREVIEW_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }} + HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} + NEON_API_KEY: ${{ secrets.NEON_API_KEY }} + NEON_PROJECT_ID: ${{ vars.NEON_PROJECT_ID }} + NEON_PREVIEW_PARENT_BRANCH_ID: ${{ vars.NEON_PREVIEW_PARENT_BRANCH_ID }} + REGISTRY_PREVIEW_PUBLISHER_TOKEN: ${{ secrets.REGISTRY_PREVIEW_PUBLISHER_TOKEN }} + PULL_NUMBER: ${{ needs.identity.outputs.pull_number }} + SOURCE_SHA: ${{ needs.identity.outputs.head_sha }} + run: | + docker load --input "$RUNNER_TEMP/registry-image/registry-image.tar" + pdm run python scripts/registry_preview.py deploy \ + --pull-number "$PULL_NUMBER" --source-sha "$SOURCE_SHA" --image registry-preview + - name: Record interactive preview evidence + env: + PREVIEW_URL: ${{ steps.deploy.outputs.url }} + SOURCE_SHA: ${{ needs.identity.outputs.head_sha }} + run: | + { + echo '## Registry PR 预览' + echo + echo "- 来源:\`$SOURCE_SHA\`" + echo "- 完整预览:$PREVIEW_URL" + echo '- 目录、详情、Publisher 与原生分发运行同一 CPython 服务。' + echo '- 每 PR 独立 Neon 分支与 R2 桶;不生成示例扩展。' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.prettierignore b/.prettierignore index 9ed9c8e..765a0aa 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,5 @@ dist/ node_modules/ pnpm-lock.yaml tasks/ +src/inkcre_extension_registry/service/templates/ +src/inkcre_extension_registry/service/static/registry.css diff --git a/AGENTS.md b/AGENTS.md index edaa874..589dc10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,10 +5,11 @@ Distribution hosting, and the independent Extension Developer Toolkit. ## Repository Map -- `src/inkcre_extension_registry/`: Registry contracts and Worker service. +- `src/inkcre_extension_registry/`: Registry contracts and CPython service. - `toolkit/`: independently released developer and delivery tooling. - `contracts/`: generated schemas and OpenAPI; never edit by hand. -- `migrations/`: checked-in D1 schema history. +- `src/inkcre_extension_registry/migrations/`: append-only Tortoise schema history. +- `migrations/`: preserved legacy D1 schema, used only to verify and import snapshots. - `docs/`: durable local knowledge; `tasks/`: volatile task packets. ## Knowledge Owners @@ -33,7 +34,9 @@ Distribution hosting, and the independent Extension Developer Toolkit. Registry service. - Never hand-edit `contracts/*`; change its source model or route and run `pnpm contracts:generate`. -- Treat checked-in D1 migrations as append-only after deployment. Production +- Treat checked-in migrations as append-only after deployment. Use Tortoise ORM + for PostgreSQL business and maintenance queries; legacy SQLite reads belong + only to the import boundary. Production resource mutation requires a separate, explicit authorization. ## Current Checks diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d678d1c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 +FROM python:3.13-slim AS builder +ENV PDM_CHECK_UPDATE=false PDM_IGNORE_SAVED_PYTHON=1 PDM_VENV_IN_PROJECT=1 +WORKDIR /app +RUN pip install --no-cache-dir pdm==2.28.0 +COPY pyproject.toml pdm.lock README.md ./ +COPY src/ src/ +COPY toolkit/ toolkit/ +COPY runtimes/core-py/ runtimes/core-py/ +RUN pdm install --prod --no-editable --frozen-lockfile + +FROM python:3.13-slim +ARG SOURCE_REVISION=unknown +LABEL org.opencontainers.image.revision="${SOURCE_REVISION}" +ENV PATH="/app/.venv/bin:${PATH}" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \ + REGISTRY_SOURCE_REVISION="${SOURCE_REVISION}" +WORKDIR /app +COPY --from=builder /app/.venv /app/.venv +RUN useradd --uid 10001 --create-home registry +USER registry +EXPOSE 8000 +CMD ["python", "-m", "inkcre_extension_registry"] diff --git a/README.md b/README.md index d701310..bb7853e 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,7 @@ browser consumers use the producer's native `mf-manifest.json`. Deployment installation, per-Peer enablement, and running state remain outside this repository. -The service runs as a Python Cloudflare Worker with D1 metadata and private R2 -bytes. It does not publish a generic target manifest or a shared Runtime/API -package. +服务使用 CPython、FastAPI / Jinja、Tortoise ORM 和 Neon PostgreSQL,私有文件继续存放于 R2,并通过 boto3 的 S3 接口访问。目录、详情、Publisher、API 与文件分发由同一个容器交付;没有独立前端部署。现有生产 Worker 的切换步骤见 [生产迁移](docs/40-deployment/production-registry.md)。 This repository also releases the independent `inkcre-extension-toolkit` developer/CD distribution. Its `cli` extra exposes @@ -28,7 +26,7 @@ use the native APIs described above. ```bash pdm install --frozen-lockfile pnpm install --frozen-lockfile -pnpm check +REGISTRY_TEST_DATABASE_URL=postgres://registry:registry-check@localhost:5432/registry_check pnpm check ``` See the [documentation index](docs/index.md) for internal design and deployment diff --git a/contracts/openapi.json b/contracts/openapi.json index dcd399c..b2d519c 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -184,6 +184,121 @@ "title": "PrepareReleaseRequest", "type": "object" }, + "PublisherRelease": { + "additionalProperties": false, + "properties": { + "module_federation": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModuleFederationDistribution" + }, + { + "type": "null" + } + ] + }, + "name": { + "maxLength": 129, + "minLength": 3, + "pattern": "^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?/[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$", + "title": "Name", + "type": "string" + }, + "nickname": { + "title": "Nickname", + "type": "string" + }, + "python": { + "anyOf": [ + { + "$ref": "#/components/schemas/PythonDistribution" + }, + { + "type": "null" + } + ] + }, + "python_uploaded": { + "title": "Python Uploaded", + "type": "boolean" + }, + "state": { + "enum": [ + "preparing", + "published", + "yanked", + "blocked" + ], + "title": "State", + "type": "string" + }, + "version": { + "maxLength": 128, + "minLength": 5, + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*)|(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))(?:\\.(?:(?:0|[1-9][0-9]*)|(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?$", + "title": "Version", + "type": "string" + }, + "web_uploaded": { + "title": "Web Uploaded", + "type": "boolean" + } + }, + "required": [ + "name", + "nickname", + "version", + "state", + "python_uploaded", + "web_uploaded" + ], + "title": "PublisherRelease", + "type": "object" + }, + "PublisherWorkspace": { + "additionalProperties": false, + "properties": { + "namespace": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$", + "title": "Namespace", + "type": "string" + }, + "next_offset": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Next Offset" + }, + "offset": { + "minimum": 0.0, + "title": "Offset", + "type": "integer" + }, + "releases": { + "items": { + "$ref": "#/components/schemas/PublisherRelease" + }, + "title": "Releases", + "type": "array" + } + }, + "required": [ + "namespace", + "releases", + "offset", + "next_offset" + ], + "title": "PublisherWorkspace", + "type": "object" + }, "PythonAssociationInput": { "additionalProperties": false, "properties": { @@ -1250,6 +1365,64 @@ }, "summary": "Yank Release" } + }, + "/v1/publisher": { + "get": { + "operationId": "publisher_workspace_v1_publisher_get", + "parameters": [ + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "maximum": 100000, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublisherWorkspace" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Publisher Workspace" + } } } } diff --git a/docs/30-unit-tdd/README.md b/docs/30-unit-tdd/README.md index 702bcbd..a8ac4a7 100644 --- a/docs/30-unit-tdd/README.md +++ b/docs/30-unit-tdd/README.md @@ -5,6 +5,7 @@ independently released units. Public schemas, routes, package metadata, and generated contracts and build checks remain executable authority. - [Registry control plane](registry-control-plane.md) +- [Registry Web](registry-web.md): discovery, publisher workspace, and design consumption. - [Extension Developer Toolkit](developer-toolkit.md) - [Core Python Extension Host Runtime](core-python-runtime.md) diff --git a/docs/30-unit-tdd/developer-toolkit.md b/docs/30-unit-tdd/developer-toolkit.md index 86bb641..5d11e32 100644 --- a/docs/30-unit-tdd/developer-toolkit.md +++ b/docs/30-unit-tdd/developer-toolkit.md @@ -4,7 +4,7 @@ base package owns pure, Registry-compatible native association projections; the `cli` extra owns HTTP and command dependencies and exposes `inkcre-ext`. The Registry service may reuse the pure library, but the Toolkit must not -depend on Registry implementation or Worker runtime state. +depend on Registry implementation or service runtime state. The Toolkit prepares Release descriptors, uploads native Module Federation snapshots, and builds deterministic static preview facades from one explicit, diff --git a/docs/30-unit-tdd/registry-control-plane.md b/docs/30-unit-tdd/registry-control-plane.md index b535da2..3947675 100644 --- a/docs/30-unit-tdd/registry-control-plane.md +++ b/docs/30-unit-tdd/registry-control-plane.md @@ -24,8 +24,7 @@ The Python surface admits bounded wheel uploads through `/legacy/`, validates the declared digest, filename, normalized project, version congruence, Core Metadata, entry point, and archive safety, then exposes PEP 503/691 and PEP 658 projections through `/simple/` and `/packages/`. The request is declared-length -and capped at 20 MiB because Python Workers cannot use Starlette's normal -thread-backed spill path. +and capped at 20 MiB. CPython uses Starlette's standard spooled multipart files. The Module Federation surface admits a bounded ZIP rooted at `mf-manifest.json`, validates its relative Remote entry and referenced assets, @@ -37,14 +36,14 @@ models, routes, generated contracts, and build checks are the exact executable i ## Persistence and Security -D1 owns identity, lifecycle, association metadata, and hashed namespace -credentials. R2 owns immutable private bytes. Admission writes staging bytes -before a conditional D1 exposure transition; unreachable staging objects are -recoverable garbage, never public authority. Every raw-byte read first proves -a readable D1 association. - -Raw credentials never enter D1 or evidence. Anonymous reads are limited to -published or yanked state. Registry admission validates structure and -integrity, not publisher trust: Python code executes in a trusted Core process -and a Module Federation Remote receives host-page privileges. Platform Hosts -must perform their own compatibility and runtime negotiation before execution. +持久化由 PostgreSQL 与 Tortoise ORM / asyncpg 承担。`service/database.py` 定义模型、业务唯一约束、外键和状态约束;`service/repository.py` 拥有查询与事务。Release、Distribution 和 File 使用独立内部主键,公开身份仍是 Extension Name / Version 或 Python Project / Version / Filename。内部 ID 不进入公开 URL 或契约。 + +准备关联时,namespace 行锁串行化同一发布者的身份创建,release 行锁保护关联和状态。唯一约束保证 Python Project / Version 不被另一个 release 占用;失败会回滚同一事务中的 Extension、Release 和关联创建。上传、发布、撤回和恢复均重新检查锁定后的 release 状态。关联、文件名和内容身份不可变,相同提交可以重试。模型的联合约束明确使用数据库外键列名;不依赖迁移生成器猜测关系字段的物理列名。 + +R2 的网络访问不持有数据库事务锁。boto3 的同步调用在线程中执行;上传先写内容寻址的 staging 对象,再在 PostgreSQL 事务中增加可见关联。发布前检查关联对象存在,文件大小一致。失败可能留下不可达的 staging 对象,它们不是公开状态的来源。每次文件读取先检查 release 可读性,再通过 S3 流式返回;HEAD 只读取对象元数据。响应使用 `Cache-Control: public, no-cache` 和内容 SHA-256 ETag;条件 GET / HEAD 在 release 可读、对象存在之后才能返回 304。缓存必须逐次重验证,blocked 的 451 响应使用 `no-store`。已经被客户端下载的内容无法通过服务端状态变化收回。 + +应用启动只初始化连接池,不建表、不运行迁移、不创建凭据或示例数据。交付在启动新版本前运行包内 Tortoise 迁移,并仅允许向前执行。数据迁移工具只在旧 SQLite 快照读取边界使用 SQL,PostgreSQL 业务读写与发布凭据维护使用 ORM。角色与 GRANT 是 PostgreSQL 原生授权 DDL,由追加迁移维护;登录密码由可信交付端设置。运行角色 `registry_app` 仅获得七张业务表的 SELECT / INSERT / UPDATE / DELETE、对应序列的 USAGE 和 schema 的 USAGE,不能修改 schema、角色或迁移记录。后续新增表须在同一迁移显式授予所需权限,不自动授权全部未来表。旧 D1 历史保留用于导入验收,迁移工具不自动访问生产 D1。 + +Publisher 持有 namespace 范围内的发布能力;原始 token 只用于请求认证和运维命令的标准输入,数据库保存 SHA-256。匿名读取只能看到公开或撤回的 release,blocked 状态拒绝描述和原始文件访问。预览服务只获得所属数据库分支和 R2 桶的凭据;云平台控制凭据只属于可信交付控制器。 + +Registry 验证文件结构与完整性,不证明发布者可信。Python 扩展在受信任 Core 进程中运行,Module Federation Remote 获得宿主页面权限;Host 继续拥有兼容性判断和运行时协商。 diff --git a/docs/30-unit-tdd/registry-web.md b/docs/30-unit-tdd/registry-web.md new file mode 100644 index 0000000..eb48cc7 --- /dev/null +++ b/docs/30-unit-tdd/registry-web.md @@ -0,0 +1,86 @@ +# Registry Web + +Registry Web is a presentation surface of the CPython service. FastAPI reads the +existing Registry repository and renders autoescaped Jinja templates. Small +browser enhancements cover theme selection, copying an Extension ID, and the +publisher workspace. There is no separately deployed frontend or browser +framework runtime. + +## Public discovery + +`/` lists published Extensions with server-rendered name search and publisher +filtering. Search is a case-insensitive substring of the canonical name or +nickname; filtering combines with search. An empty Registry and empty search +results have different recovery actions. The page remains useful without JavaScript. + +`/explore/{namespace}/{name}` displays published releases and their admitted +native distribution descriptors. Versions are sorted by SemVer; the default +selection is the highest stable version, or the highest pre-release if there +is no stable release. The optional `version` query selects an exact published +version. Missing or withdrawn versions return a human-readable 404. Existing +`/v1`, `/simple`, `/packages`, and Module Federation asset paths retain their +machine-facing semantics. + +The UI does not invent descriptions, popularity, trust badges, compatibility +verdicts, installation state, or download counts that Registry does not own. +Package inspection and copying an ID do not install or activate an Extension. + +## Publisher workspace + +`/publish` connects a namespace credential kept only in the current page's +memory. A reload, navigation away, or disconnect clears it. Credentials are +sent in Authorization headers to the same origin, never in URLs, local storage, +HTML, or logs. Only the theme preference is persisted in local storage. + +Connect、复制 ID 和主题按钮在各自的事件处理器绑定后才启用,避免脚本加载期间丢失用户操作。凭据输入框没有原生表单字段名,因此脚本加载延迟或失败时,浏览器不能把凭据作为 URL 查询参数提交;认证仅由脚本通过 Authorization 发出。 + +`GET /v1/publisher?offset=0` authenticates through the existing namespace +credential dependency and returns only that namespace's releases, including +preparing, withdrawn, and operator-blocked releases. Pages contain at most ten +records. Related distribution and file data is prefetched in bounded queries. +`next_offset` indicates another page; concurrent publication can shift this +non-snapshot listing, and Refresh reloads current state. Private responses, +including rejected credentials, are not cached. Per-distribution uploaded flags +come from the admitted files/manifest association, not a browser assumption. + +The Web release form calls the existing prepare endpoint, uploads a Module +Federation ZIP through the existing multipart endpoint, and asks for a separate +publication decision. Failed preparation or upload keeps the form available +for correction; published bytes and metadata remain immutable. The workspace +also withdraws and restores releases through existing lifecycle endpoints. +A failed refresh after a successful mutation is reported separately from a +failed mutation. Timeout messages tell publishers to check state before retrying. + +Python releases continue to use the Developer Toolkit/native Python upload +protocol. They appear in the same workspace for publication and lifecycle +management. Namespace and credential issuance remains an operator responsibility. + +## Design ownership + +`@inkcre/ui-web` is the existing package released by the sibling `design` +repository (`InKCre/ui`). Registry installs the package from its immutable public release commit +using pnpm’s Git subdirectory support, and imports its public Sass styles, token maps, +and functions. This avoids requiring private GitHub Packages access in public CI; +compiled Vue exports are not part of this consumer contract. Colors, typography families, and common spacing/radius vocabulary +remain design-owned; `web/registry.scss` owns Registry page composition. There +is no Registry copy of the token source or a competing design package. + +`pnpm web:build` compiles the pinned package and local Sass into the committed +`service/static/registry.css`. `pnpm web:check` compares it to a fresh compilation +and checks browser JavaScript syntax. Committing generated CSS makes Python wheel +and container builds self-contained; it is never edited manually. The wheel +includes templates, CSS and JavaScript in the ordinary Python package. +Rendering depends on product data, selected release, and the current route. +Templates, links, controls, and browser styles contain no preview/production +switch. Instance origin and storage connections belong to runtime configuration. + +Remote previews run the same CPython image with an isolated PostgreSQL branch and R2 bucket. Their +resource lifecycle and source identity belong to the delivery controller; see +[Pull-Request Previews](../40-deployment/pull-request-previews.md). + +Registry follows the client-web product surface: the 32px geometric InKCre logo, +InkHeader spacing and typography, square InkButton-style controls, bordered +extension rows, and compact metadata. `templates/brand.html` contains the SVG +from client-web's `apps/client-web/public/logo/32.svg`. Page content consists of +catalog, release metadata, and publishing controls; it has no marketing hero, +illustration, promotional footer, or persistent tutorial panels. diff --git a/docs/40-deployment/README.md b/docs/40-deployment/README.md index 70bddfd..e93426e 100644 --- a/docs/40-deployment/README.md +++ b/docs/40-deployment/README.md @@ -4,6 +4,6 @@ - [Production Registry](production-registry.md) - [Pull-request previews](pull-request-previews.md) -Workflow files and `wrangler.jsonc` are executable delivery authority. These +Workflow files and `Dockerfile` are executable delivery authority. These documents own the operational intent, safety boundaries, failure behavior, and recovery facts that those files cannot express clearly enough. diff --git a/docs/40-deployment/local-development.md b/docs/40-deployment/local-development.md index 057ca99..ba1b3c3 100644 --- a/docs/40-deployment/local-development.md +++ b/docs/40-deployment/local-development.md @@ -1,32 +1,33 @@ -# Local Development +# 本地开发 -Install frozen Python and Node dependencies and run the full contract: +Registry 使用 Python 3.13、PDM 和 Node 22 / pnpm。Node 只用于现有设计样式、契约及独立 Web Runtime 包的构建;服务进程只运行 Python。 ```bash pdm install --frozen-lockfile pnpm install --frozen-lockfile -pnpm check ``` -For the real local Worker seam, use isolated Wrangler state or a clean checkout: +配置 `DATABASE_URL`、`PUBLIC_ORIGIN`、`S3_ENDPOINT_URL`、`S3_BUCKET` 和标准 AWS 凭据 `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`。数据库必须是 PostgreSQL;远程连接默认验证 TLS,localhost 可以不启用 TLS。`PUBLIC_ORIGIN` 是 HTTPS origin,本地允许 localhost HTTP,不含路径。文件配置是 dotenv 数据,不应当作 shell 脚本执行;应用从进程环境读取配置。 ```bash -pnpm exec wrangler d1 migrations apply DB --local --config wrangler.jsonc -pdm run pywrangler dev --port 8791 \ - --var PUBLIC_ORIGIN:http://127.0.0.1:8791 +pnpm db:migrate +pdm run python -m inkcre_extension_registry ``` -`.wrangler/`, virtual environments, `python_modules/`, dependency directories, -build output, and secrets are local state and must remain uncommitted. +默认监听 8000,平台通过 `PORT` 指定端口。`/`、`/explore//`、`/publish` 和全部 API 使用同一服务。新库显示真实空态;启动和迁移不会创建示例数据。运维可通过 `pdm run python -m inkcre_extension_registry.admin grant --label