Skip to content

Commit 10bb9dc

Browse files
committed
Verify project link works in config validate
1 parent a517149 commit 10bb9dc

9 files changed

Lines changed: 121 additions & 28 deletions

File tree

acceptance/config_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ package acceptance_test
2424

2525
import (
2626
"encoding/json"
27+
"fmt"
2728
"os"
2829
"path/filepath"
2930
"testing"
@@ -248,6 +249,48 @@ func TestConfigValidate_InfersOrgFromGitRemote(t *testing.T) {
248249
assert.Check(t, cmp.Equal(fake.LastCompileOwnerID(), testOrgUUID))
249250
}
250251

252+
// TestConfigValidate_ProjectLinkOverridesGitRemote pins that org inference
253+
// honours a `circleci project link` binding (.circleci/info.yml) over the git
254+
// remote — the case where the remote is not the right answer (repository
255+
// renames, forks, standalone projects). The remote points at one org; the link
256+
// binds a different one, and the linked org must reach the compile endpoint.
257+
//
258+
// The linked project is deliberately NOT registered with the fake: `project
259+
// link` records the org UUID in info.yml, so inference uses it directly with no
260+
// project lookup. The linked org resolving without a matching fake project
261+
// proves that direct-ID path (which also works offline / with a restricted
262+
// token).
263+
func TestConfigValidate_ProjectLinkOverridesGitRemote(t *testing.T) {
264+
const linkedOrgUUID = "00000000-0000-0000-0000-0000000000cc"
265+
const linkedProjUUID = "00000000-0000-0000-0000-0000000000dd"
266+
267+
fake := fakes.NewCircleCI(t)
268+
fake.SetCompileResponse(true, testCompiledYAML)
269+
// The git remote resolves to a *different* org. If the link were ignored,
270+
// this org would be used and the assertion below would catch it.
271+
fake.AddProjectBySlug("gh/otherorg/otherrepo", "00000000-0000-0000-0000-0000000000b3", "otherrepo", testOrgUUID)
272+
273+
env := testenv.New(t)
274+
env.Token = testToken
275+
env.CircleCIURL = fake.URL()
276+
277+
dir := t.TempDir()
278+
initGitRepoWithRemote(t, dir, "https://github.com/otherorg/otherrepo")
279+
writeConfig(t, dir, testConfigYAML)
280+
writeProjectLink(t, dir, linkedOrgUUID, linkedProjUUID)
281+
282+
result := binary.RunCLI(t, binary.RunOpts{
283+
Binary: binaryPath,
284+
Args: []string{"config", "validate", "--config", ".circleci/config.yml"},
285+
Env: env.Environ(),
286+
WorkDir: dir,
287+
})
288+
289+
assert.Check(t, cmp.Equal(result.ExitCode, 0), "stderr: %s", result.Stderr)
290+
// The linked project's org — not the git remote's — reached compile.
291+
assert.Check(t, cmp.Equal(fake.LastCompileOwnerID(), linkedOrgUUID))
292+
}
293+
251294
// TestConfigValidate_NoOrgOutsideGitRemote guards the other half of the #1061
252295
// contract: inference is best-effort. Outside a git checkout (and with no
253296
// --org) validation still succeeds, compiling with an empty owner_id rather
@@ -447,6 +490,18 @@ func writeConfig(t *testing.T, dir, content string) {
447490
writeFile(t, filepath.Join(dir, ".circleci", "config.yml"), content)
448491
}
449492

493+
// writeProjectLink writes the .circleci/info.yml that `circleci project link`
494+
// produces, binding the checkout to a CircleCI project by its stable UUIDs.
495+
func writeProjectLink(t *testing.T, dir, orgID, projectID string) {
496+
t.Helper()
497+
assert.NilError(t, os.MkdirAll(filepath.Join(dir, ".circleci"), 0o755))
498+
content := fmt.Sprintf(
499+
"organization:\n id: %s\nproject:\n id: %s\n slug: circleci/%s/%s\n",
500+
orgID, projectID, orgID, projectID,
501+
)
502+
writeFile(t, filepath.Join(dir, ".circleci", "info.yml"), content)
503+
}
504+
450505
func writeFile(t *testing.T, path, content string) {
451506
t.Helper()
452507
assert.NilError(t, os.WriteFile(path, []byte(content), 0o644))

internal/cmd/config/config.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,22 @@ func readConfigInput(ctx context.Context, path string) (string, error) {
9898
// resolveOrgID returns the org UUID to use for private orb resolution during
9999
// compilation.
100100
//
101-
// When --org is empty the org is inferred from the git remote as a best-effort
102-
// convenience, so configs that reference private or namespaced orbs validate
103-
// without requiring the flag. If the org can't be determined (not a git
104-
// checkout, an unrecognised remote, or the project isn't found) it falls back
105-
// to "" and private orb resolution is skipped — the compile call still
106-
// proceeds so public configs validate anywhere.
101+
// When --org is empty the org is inferred from the current project as a
102+
// best-effort convenience, so configs that reference private or namespaced orbs
103+
// validate without requiring the flag. Inference honours a `circleci project
104+
// link` binding first and falls back to the git remote (see cmdutil.InferOrgID).
105+
// If the org can't be determined (not a linked or git checkout, an unrecognised
106+
// remote, or the project isn't found) it falls back to "" and private orb
107+
// resolution is skipped — the compile call still proceeds so public configs
108+
// validate anywhere.
107109
//
108110
// When --org is set explicitly, the slug or UUID is resolved via the API and an
109111
// unresolvable value is a hard error, so a typo isn't silently ignored.
110112
//
111113
// cmdName is used only in the suggestion text of any resulting error.
112114
func resolveOrgID(ctx context.Context, client *apiclient.Client, org, cmdName string) (string, error) {
113115
if org == "" {
114-
return cmdutil.InferOrgIDFromGitRemote(ctx, client), nil
116+
return cmdutil.InferOrgID(ctx, client), nil
115117
}
116118
id, err := cmdutil.ResolveOrgSlugOrID(ctx, client, org, cmdName)
117119
if err != nil {

internal/cmd/config/process.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,10 @@ func newProcessCmd() *cobra.Command {
6464
Pass a file path or "-" to read from stdin.
6565
6666
Private and namespaced orbs are resolved against your organization.
67-
The org is inferred from the git remote when --org is omitted; pass
68-
--org to override it or to resolve private orbs outside a checkout.
67+
When --org is omitted the org is inferred from the current project —
68+
a 'circleci project link' binding if present, otherwise the git
69+
remote. Pass --org to override this, or to resolve private orbs
70+
outside a project.
6971
`),
7072
Example: heredoc.Doc(`
7173
# Process the default config

internal/cmd/config/validate.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ func newValidateCmd() *cobra.Command {
5252
different file, or "-" to read from stdin.
5353
5454
Private and namespaced orbs are resolved against your organization.
55-
The org is inferred from the git remote when --org is omitted; pass
56-
--org to override it or to resolve private orbs outside a checkout.
55+
When --org is omitted the org is inferred from the current project —
56+
a 'circleci project link' binding if present, otherwise the git
57+
remote. Pass --org to override this, or to resolve private orbs
58+
outside a project.
5759
5860
JSON fields (--json):
5961
valid bool whether the config compiled without errors

internal/cmd/root/testdata/help/circleci/config/process.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ CircleCI would actually execute.
99
Pass a file path or "-" to read from stdin.
1010

1111
Private and namespaced orbs are resolved against your organization.
12-
The org is inferred from the git remote when --org is omitted; pass
13-
--org to override it or to resolve private orbs outside a checkout.
12+
When --org is omitted the org is inferred from the current project —
13+
a 'circleci project link' binding if present, otherwise the git
14+
remote. Pass --org to override this, or to resolve private orbs
15+
outside a project.
1416

1517
## Usage
1618

internal/cmd/root/testdata/help/circleci/config/validate.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ Reads .circleci/config.yml by default. Pass --config to specify a
66
different file, or "-" to read from stdin.
77

88
Private and namespaced orbs are resolved against your organization.
9-
The org is inferred from the git remote when --org is omitted; pass
10-
--org to override it or to resolve private orbs outside a checkout.
9+
When --org is omitted the org is inferred from the current project —
10+
a 'circleci project link' binding if present, otherwise the git
11+
remote. Pass --org to override this, or to resolve private orbs
12+
outside a project.
1113

1214
JSON fields (--json):
1315
valid bool whether the config compiled without errors

internal/cmdutil/orgid.go

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,21 @@ func ResolveOrgSlugOrID(ctx context.Context, client *apiclient.Client, ref, cmdN
101101
return parseOrgID(org.ID)
102102
}
103103

104-
// InferOrgIDFromGitRemote best-effort resolves the org UUID for the current
105-
// git remote's project. It is the lenient counterpart to ResolveOrgSlugOrID,
106-
// for commands where the org is an optional convenience rather than required —
107-
// e.g. config compilation passes it so private and namespaced orbs resolve
108-
// without an explicit --org. It returns "" (with no error) whenever the org
109-
// cannot be determined — not a git checkout, an unrecognised remote, or a
110-
// failed project lookup — so callers fall back to public-only behaviour
111-
// instead of failing.
112-
func InferOrgIDFromGitRemote(ctx context.Context, client *apiclient.Client) string {
104+
// InferOrgID best-effort resolves the org UUID for the current directory's
105+
// CircleCI project. Detection follows gitremote.Detect's resolution order — a
106+
// `circleci project link` binding (.circleci/info.yml) takes precedence over
107+
// the git remote, so an explicit link wins when the remote is not the right
108+
// answer (repository renames, forks, standalone projects). When the link
109+
// recorded the org as a UUID it is used directly; otherwise the resolved
110+
// project is looked up through the API to recover its owning org.
111+
//
112+
// It is the lenient counterpart to ResolveOrgSlugOrID, for commands where the
113+
// org is an optional convenience rather than required — e.g. config compilation
114+
// passes it so private and namespaced orbs resolve without an explicit --org.
115+
// It returns "" (with no error) whenever the org cannot be determined — not a
116+
// git checkout, an unrecognised remote, or a failed project lookup — so callers
117+
// fall back to public-only behaviour instead of failing.
118+
func InferOrgID(ctx context.Context, client *apiclient.Client) string {
113119
// The hint strings are unused: any error is swallowed into a "" result.
114120
raw, err := orgIDFromGitRemote(ctx, client, "", "")
115121
if err != nil {
@@ -128,6 +134,16 @@ func orgIDFromGitRemote(ctx context.Context, client *apiclient.Client, detectHin
128134
if err != nil {
129135
return "", GitDetectErr(err, detectHint)
130136
}
137+
// A `circleci project link` binding records the org ID directly. When it is a
138+
// UUID — the same form the project lookup below returns — use it as-is and
139+
// skip the API round-trip, so the org resolves even offline or with a token
140+
// that can't read the project. A non-UUID (compact) recorded ID falls through
141+
// to the lookup, which yields the canonical UUID.
142+
if info.OrgID != "" {
143+
if id, err := uuid.Parse(info.OrgID); err == nil {
144+
return id.String(), nil
145+
}
146+
}
131147
proj, err := client.GetProjectBySlug(ctx, info.Slug)
132148
if err != nil {
133149
return "", clierrors.New("org.resolve_failed", "Could not resolve organization",

internal/gitremote/detect.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ type ProjectInfo struct {
4646
Branch string
4747
// DefaultBranch is the default branch name.
4848
DefaultBranch string
49+
// OrgID is the organization ID recorded by `circleci project link`
50+
// (.circleci/info.yml). It is empty when the project was resolved from the
51+
// git remote, because the org ID is not derivable from a remote URL without
52+
// an API lookup. Its form is whatever link persisted (a UUID, or a compact
53+
// base62 ID); consumers that need a UUID must parse and fall back on failure.
54+
OrgID string
4955
}
5056

5157
var (
@@ -111,6 +117,7 @@ func Detect() (*ProjectInfo, error) {
111117
Slug: ref.EffectiveSlug(),
112118
Branch: branch,
113119
DefaultBranch: defaultBranch,
120+
OrgID: ref.Organization.ID,
114121
}, nil
115122
}
116123
}

internal/gitremote/detect_test.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,10 @@ func TestSlugFromRemote(t *testing.T) {
155155
// to the canonical "circleci/<orgID>/<projectID>" slug.
156156
func TestDetect_PrefersInfoYml(t *testing.T) {
157157
tests := []struct {
158-
name string
159-
info projectref.Info
160-
wantSlug string
158+
name string
159+
info projectref.Info
160+
wantSlug string
161+
wantOrgID string
161162
}{
162163
{
163164
name: "uuids present yields canonical slug",
@@ -168,7 +169,8 @@ func TestDetect_PrefersInfoYml(t *testing.T) {
168169
ID: "13c8F7nusayivoSxC6GMsw",
169170
},
170171
},
171-
wantSlug: "circleci/E6i3yYZeWZhcf8UNqcKfjN/13c8F7nusayivoSxC6GMsw",
172+
wantSlug: "circleci/E6i3yYZeWZhcf8UNqcKfjN/13c8F7nusayivoSxC6GMsw",
173+
wantOrgID: "E6i3yYZeWZhcf8UNqcKfjN",
172174
},
173175
{
174176
name: "slug-only falls through verbatim",
@@ -190,6 +192,9 @@ func TestDetect_PrefersInfoYml(t *testing.T) {
190192
info, err := Detect()
191193
assert.NilError(t, err)
192194
assert.Check(t, cmp.Equal(info.Slug, tc.wantSlug))
195+
// The org ID recorded by `project link` is surfaced verbatim so
196+
// callers can use it without a project lookup.
197+
assert.Check(t, cmp.Equal(info.OrgID, tc.wantOrgID))
193198
})
194199
}
195200
}

0 commit comments

Comments
 (0)