Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/56.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Registry Web release pages can return to a Web app with an exact Extension version for installation confirmation.
19 changes: 19 additions & 0 deletions .github/workflows/registry-preview-cleanup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
timeout-minutes: 15
environment:
name: preview
deployment: false
steps:
- name: Checkout the trusted cleanup controller
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -111,3 +112,21 @@ jobs:
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"
- name: Mark the retired PR deployments inactive
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PULL_NUMBER: ${{ steps.identity.outputs.pull_number }}
with:
script: |
const deployments = await github.paginate(github.rest.repos.listDeployments, {
...context.repo, environment: 'preview', per_page: 100,
})
for (const deployment of deployments) {
if (deployment.payload?.pull_number !== Number(process.env.PULL_NUMBER)) continue
await github.rest.repos.createDeploymentStatus({
...context.repo,
deployment_id: deployment.id,
state: 'inactive',
auto_inactive: false,
})
}
85 changes: 83 additions & 2 deletions .github/workflows/registry-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,62 @@ jobs:
contents: read
pull-requests: read
deployments: write
statuses: write
runs-on: ubuntu-latest
timeout-minutes: 20
environment:
name: preview
url: ${{ steps.deploy.outputs.url }}
deployment: false
steps:
- name: Verify 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: Create the exact pull-request deployment
id: deployment
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: deployment } = await github.rest.repos.createDeployment({
...context.repo,
ref: process.env.SOURCE_SHA,
environment: 'preview',
auto_merge: false,
required_contexts: [],
transient_environment: true,
production_environment: false,
payload: { pull_number: Number(process.env.PULL_NUMBER) },
description: 'Registry pull-request preview',
})
core.setOutput('deployment_id', String(deployment.id))
await github.rest.repos.createDeploymentStatus({
...context.repo,
deployment_id: deployment.id,
state: 'in_progress',
log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
})
await github.rest.repos.createCommitStatus({
...context.repo,
sha: process.env.SOURCE_SHA,
context: 'ext-reg preview',
state: 'pending',
description: 'Registry preview is deploying',
target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
})
- name: Checkout only the trusted controller
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down Expand Up @@ -157,7 +207,7 @@ jobs:
with:
name: registry-image
path: ${{ runner.temp }}/registry-image
- name: Recheck PR identity after build and concurrency queue
- name: Recheck PR identity before preview mutation
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PULL_NUMBER: ${{ needs.identity.outputs.pull_number }}
Expand Down Expand Up @@ -201,3 +251,34 @@ jobs:
echo '- 目录、详情、Publisher 与原生分发运行同一 CPython 服务。'
echo '- 每 PR 独立 Neon 分支与 R2 桶;不生成示例扩展。'
} >> "$GITHUB_STEP_SUMMARY"
- name: Report the exact pull-request preview
if: ${{ always() && steps.deployment.outputs.deployment_id != '' }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }}
SOURCE_SHA: ${{ needs.identity.outputs.head_sha }}
PREVIEW_RESULT: ${{ job.status }}
PREVIEW_URL: ${{ steps.deploy.outputs.url }}
with:
script: |
const succeeded = process.env.PREVIEW_RESULT === 'success' && Boolean(process.env.PREVIEW_URL)
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
await github.rest.repos.createDeploymentStatus({
...context.repo,
deployment_id: Number(process.env.DEPLOYMENT_ID),
state: succeeded ? 'success' : 'failure',
log_url: runUrl,
auto_inactive: false,
...(succeeded ? { environment_url: process.env.PREVIEW_URL } : {}),
})
await github.rest.repos.createCommitStatus({
...context.repo,
sha: process.env.SOURCE_SHA,
context: 'ext-reg preview',
state: succeeded ? 'success' : 'error',
description: succeeded ? 'Registry preview is ready' : 'Registry preview failed',
target_url: succeeded ? process.env.PREVIEW_URL : runUrl,
})
if (!succeeded && process.env.PREVIEW_RESULT === 'success') {
core.setFailed('Preview deployment did not report a URL.')
}
13 changes: 12 additions & 1 deletion docs/30-unit-tdd/registry-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,20 @@ 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 detail page links to client-web's `/extensions?install=<name>&version=<exact>`.
The Registry neither reads deployment state nor installs the Release. When a
client-web page opens the Registry, it passes only its Web origin as
`client_origin`; the catalog preserves it through search, filters, and version
links. The Registry accepts HTTPS origins (and local HTTP for development),
then constructs a fixed client-web path and displays the destination host. A
direct Registry visit defaults to `https://app.inkcre.dev`. The return query
contains only the Extension name and exact version; client-web reads the
Release anew from its own configured Registry and requires confirmation.

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.
Package inspection, copying an ID, and opening the install link do not install
or activate an Extension.

## Publisher workspace

Expand Down
2 changes: 1 addition & 1 deletion docs/40-deployment/pull-request-previews.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ GitHub `preview` 环境需要:
| `CLOUDFLARE_PREVIEW_API_TOKEN` secret | 创建和清理 R2 桶、创建和撤销桶范围的账户 API token |
| `REGISTRY_PREVIEW_PUBLISHER_TOKEN` secret | 随机审阅凭据,只登记 `reviewer` namespace |

Cloudflare 控制 token 的账户级权限只属于可信 runner。应用拿到的 S3 key 来自单个桶的 `Workers R2 Storage Bucket Item Write` token;不要直接把账户管理 token 放入应用。部署只登记审阅者凭据,不生成示例扩展、版本或文件。发布验收应在一次性环境完成,共享预览仅保留明确用于人工审阅的数据。
Cloudflare 控制 token 的账户级权限只属于可信 runner。应用拿到的 S3 key 来自单个桶的 `Workers R2 Storage Bucket Item Write` token;不要直接把账户管理 token 放入应用。部署保留 `reviewer` namespace 的审阅凭据,并通过正式 Publisher API 在每个 PR 的隔离库和桶内发布一个固定的 `inkcre/rss` 0.2.1 Release。wheel 从公开生产 Registry 下载,按固定 SHA-256 核验后上传;关联元数据与生产发行的源码提交、构建号一致,不复制生产数据库或凭据。控制器为播种临时授予 `inkcre` namespace 凭据,发布后立即撤销。后续部署核对现有 Release 和 wheel,不覆盖人工审阅数据;固定产物不可用或预览中存在冲突时交付失败,不将空目录或错误产物报告为可用。此固定 Release 用于浏览目录、版本页和经预览 Registry 实际安装的验收,不代表生产目录快照。

关闭 PR 由可信 cleanup 工作流核验同仓库与 closed 状态,先停止 app,再清理桶对象、桶和对象 token,删除数据库分支与 app。清理失败保留可定位的资源信息,重新运行同一 PR 的清理命令;不得通过范围搜索删除其他 PR、基础分支或生产资源。

Expand Down
15 changes: 14 additions & 1 deletion scripts/check_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ async def request(method: str, path: str, expected: int = 200, *, private=False,
}
assert (await request("GET", "/v1/extensions")).json() == []
await request("GET", "/")
await request("GET", "/?client_origin=https://example.invalid/path", 400)
await request("GET", "/?client_origin=https://user@example.invalid", 400)
await request("GET", "/v1/publisher", 401)
await request(
"POST", "/v1/extensions/other/extension/releases", 403, private=True, json=association
Expand Down Expand Up @@ -156,7 +158,18 @@ async def request(method: str, path: str, expected: int = 200, *, private=False,
await request("POST", version_path + "/publish", private=True)
await request("POST", version_path + "/publish", private=True)
assert (await request("GET", "/v1/extensions")).json()[0]["name"] == "check/extension"
await request("GET", "/explore/check/extension?version=1.0.0")
catalog = await request("GET", "/?client_origin=https://preview.example")
assert 'name="client_origin" value="https://preview.example"' in catalog.text
assert "/explore/check/extension?client_origin=https%3A" in catalog.text
detail = await request(
"GET", "/explore/check/extension?version=1.0.0&client_origin=https://preview.example"
)
assert (
'href="https://preview.example/extensions?install=check%2Fextension&amp;version=1.0.0"'
in detail.text
)
assert "version=1.0.0&amp;client_origin=https%3A" in detail.text
assert "Install in preview.example" in detail.text
await request("GET", "/v1/extensions/check/extension")
assert (await request("GET", version_path)).json()["state"] == "published"
await request("GET", "/simple/")
Expand Down
39 changes: 39 additions & 0 deletions scripts/registry_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import httpx
from botocore.config import Config
from registry_database import configure_runtime_login
from registry_preview_seed import seed


def request(client: httpx.Client, method: str, path: str, *, missing=False, **kwargs):
Expand Down Expand Up @@ -245,6 +246,44 @@ def deploy(heroku, neon, cloudflare, name: str, branch_name: str, image: str, re
run("heroku", "container:release", "web", "--app", name)
run("heroku", "ps:scale", "web=1:eco", "--app", name)
smoke(origin, revision)
seed_token = secrets.token_urlsafe(32)
run(
"docker",
"run",
"--rm",
"-i",
"--env",
"DATABASE_URL",
image,
"python",
"-m",
"inkcre_extension_registry.admin",
"grant",
"inkcre",
"--label",
"preview fixture",
env=database_env,
input=seed_token + "\n",
)
try:
seed(origin, seed_token)
finally:
run(
"docker",
"run",
"--rm",
"--env",
"DATABASE_URL",
image,
"python",
"-m",
"inkcre_extension_registry.admin",
"revoke",
"inkcre",
"--label",
"preview fixture",
env=database_env,
)
print(f"Preview: {origin}\nSource: {revision}\nNeon branch: {branch['id']}")
if os.environ.get("GITHUB_OUTPUT"):
with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output:
Expand Down
107 changes: 107 additions & 0 deletions scripts/registry_preview_seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Publish one pinned, installable Release into an isolated Registry preview."""

from __future__ import annotations

import argparse
import hashlib
import os

import httpx

VERSION = "0.2.1"
WHEEL_NAME = "inkcre_ext_rss-0.2.1-py3-none-any.whl"
WHEEL_SHA256 = "190ff7d15b11a341c2367b87b2348832e7fe7d8042da512f2996926645a6d275"
WHEEL_URL = f"https://registry.inkcre.dev/packages/inkcre-ext-rss/{VERSION}/{WHEEL_NAME}"
RELEASE_PATH = f"/v1/extensions/inkcre/rss/releases/{VERSION}"
PACKAGE_PATH = f"/packages/inkcre-ext-rss/{VERSION}/{WHEEL_NAME}"


def seed(origin: str, token: str) -> None:
with httpx.Client(
base_url=origin, headers={"Authorization": f"Bearer {token}"}, timeout=60
) as registry:
current = registry.get(RELEASE_PATH)
if current.status_code == 404:
wheel_response = httpx.get(WHEEL_URL, timeout=60)
wheel_response.raise_for_status()
wheel = wheel_response.content
if hashlib.sha256(wheel).hexdigest() != WHEEL_SHA256:
raise RuntimeError("Pinned RSS preview wheel digest changed")

prepared = registry.post(
"/v1/extensions/inkcre/rss/releases",
json={
"nickname": "RSS/Atom Feeds",
"version": VERSION,
"python": {
"project": "inkcre-ext-rss",
"host_sdk": "core-py",
"host_sdk_version": ">=0.2.0 <0.4.0",
"entry_point": {
"group": "inkcre.core.extensions",
"name": "rss",
"object": "extensions.rss:Extension",
},
"source_repository": "https://github.com/InKCre/core-py",
"source_revision": "8f7a17343c13f6c4af19e5ea9b58ceb6b13654c2",
"build_id": "35591185410",
},
},
)
prepared.raise_for_status()
uploaded = registry.post(
"/legacy/",
data={
":action": "file_upload",
"protocol_version": "1",
"name": "inkcre-ext-rss",
"version": VERSION,
"filetype": "bdist_wheel",
"metadata_version": "2.4",
"sha256_digest": WHEEL_SHA256,
},
files={"content": (WHEEL_NAME, wheel, "application/octet-stream")},
)
uploaded.raise_for_status()
published = registry.post(RELEASE_PATH + "/publish")
published.raise_for_status()
else:
current.raise_for_status()

release = registry.get(RELEASE_PATH)
release.raise_for_status()
record = release.json()
if (
record["name"] != "inkcre/rss"
or record["version"] != VERSION
or record["state"] != "published"
or record["nickname"] != "RSS/Atom Feeds"
or record["python"]["project"] != "inkcre-ext-rss"
or record["python"]["host_sdk"] != "core-py"
or record["python"]["host_sdk_version"] != ">=0.2.0 <0.4.0"
or record["python"]["entry_point"]
!= {
"group": "inkcre.core.extensions",
"name": "rss",
"object": "extensions.rss:Extension",
}
or record["module_federation"] is not None
):
raise RuntimeError("Preview RSS Release conflicts with the pinned fixture")
package = registry.head(PACKAGE_PATH)
package.raise_for_status()
if package.headers.get("etag") != f'"{WHEEL_SHA256}"':
raise RuntimeError("Preview RSS wheel does not match the pinned fixture")
catalog = registry.get("/v1/extensions")
catalog.raise_for_status()
if not any(item["name"] == "inkcre/rss" for item in catalog.json()):
raise RuntimeError("Preview RSS Release is missing from the catalog")
registry.get(f"/explore/inkcre/rss?version={VERSION}").raise_for_status()
print(f"Preview fixture ready: inkcre/rss {VERSION}")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--origin", required=True)
args = parser.parse_args()
seed(args.origin, os.environ["INKCRE_PREVIEW_SEED_TOKEN"])
Loading
Loading